diff --git a/package.json b/package.json index 6483b19f..fee06519 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react-redux": "^9.1.2", "react-router": "6", "react-router-dom": "6", + "react-spinners": "^0.14.1", "react-syntax-highlighter": "^15.5.0", "redux-persist": "^6.0.0", "sass": "^1.77.2", diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index ee07999c..ec3f1096 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -151,3 +151,15 @@ body, padding-bottom: 12px; } } + +.loader { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #000; + box-shadow: + 32px 0 #000, + -32px 0 #000; + position: relative; + animation: flash 0.5s ease-out infinite alternate; +} diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 3a531dfc..8faadd2d 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -78,7 +78,8 @@ const resources = { 'settings.conext_count.tip': 'The number of previous messages to keep in the context.', 'settings.reset': 'Reset', 'settings.set_as_default': 'Apply to default assistant', - 'settings.max': 'Max' + 'settings.max': 'Max', + 'suggestions.title': 'Suggested Questions' }, apps: { title: 'Agents' @@ -262,7 +263,8 @@ const resources = { '要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10,代码生成建议 5-10', 'settings.reset': '重置', 'settings.set_as_default': '应用到默认助手', - 'settings.max': '不限' + 'settings.max': '不限', + 'suggestions.title': '建议的问题' }, apps: { title: '智能体' diff --git a/src/renderer/src/pages/home/components/Messages.tsx b/src/renderer/src/pages/home/components/Messages.tsx index 59c94909..ebdf7f5f 100644 --- a/src/renderer/src/pages/home/components/Messages.tsx +++ b/src/renderer/src/pages/home/components/Messages.tsx @@ -4,13 +4,14 @@ import localforage from 'localforage' import { FC, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' import MessageItem from './Message' -import { reverse } from 'lodash' +import { debounce, reverse } from 'lodash' import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api' import { useAssistant } from '@renderer/hooks/useAssistant' import { estimateHistoryTokenCount, runAsyncFunction } from '@renderer/utils' import LocalStorage from '@renderer/services/storage' import { useProviderByAssistant } from '@renderer/hooks/useProvider' import { t } from 'i18next' +import Suggestions from './Suggestions' interface Props { assistant: Assistant @@ -93,9 +94,18 @@ const Messages: FC = ({ assistant, topic }) => { }) }, [topic.id]) + const scrollTop = useCallback( + debounce(() => containerRef.current?.scrollTo({ top: 100000, behavior: 'auto' }), 500, { + leading: true, + trailing: false + }), + [] + ) + useEffect(() => { - containerRef.current?.scrollTo({ top: 100000, behavior: 'auto' }) - }, [messages]) + setTimeout(scrollTop, 100) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages, lastMessage]) useEffect(() => { EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, estimateHistoryTokenCount(assistant, messages)) @@ -103,6 +113,7 @@ const Messages: FC = ({ assistant, topic }) => { return ( + {lastMessage && } {reverse([...messages]).map((message, index) => ( diff --git a/src/renderer/src/pages/home/components/Suggestions.tsx b/src/renderer/src/pages/home/components/Suggestions.tsx new file mode 100644 index 00000000..29d227ac --- /dev/null +++ b/src/renderer/src/pages/home/components/Suggestions.tsx @@ -0,0 +1,119 @@ +import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' +import { Assistant, Message, Suggestion } from '@renderer/types' +import { uuid } from '@renderer/utils' +import dayjs from 'dayjs' +import { FC, useEffect, useState } from 'react' +import styled from 'styled-components' +import BeatLoader from 'react-spinners/BeatLoader' +import { fetchSuggestions } from '@renderer/services/api' + +interface Props { + assistant: Assistant + messages: Message[] + lastMessage: Message | null +} + +const suggestionsMap = new Map() + +const Suggestions: FC = ({ assistant, messages, lastMessage }) => { + const [suggestions, setSuggestions] = useState( + suggestionsMap.get(messages[messages.length - 1]?.id) || [] + ) + const [loadingSuggestions, setLoadingSuggestions] = useState(false) + + const onClick = (s: Suggestion) => { + const message: Message = { + id: uuid(), + role: 'user', + content: s.content, + assistantId: assistant.id, + topicId: assistant.topics[0].id || uuid(), + createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + status: 'success' + } + + EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) + } + + useEffect(() => { + const unsubscribes = [ + EventEmitter.on(EVENT_NAMES.AI_CHAT_COMPLETION, async (msg: Message) => { + setLoadingSuggestions(true) + const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] }) + if (_suggestions.length) { + setSuggestions(_suggestions) + suggestionsMap.set(msg.id, _suggestions) + } + setLoadingSuggestions(false) + }) + ] + return () => unsubscribes.forEach((unsub) => unsub()) + }, [assistant, messages]) + + useEffect(() => { + setSuggestions(suggestionsMap.get(messages[messages.length - 1]?.id) || []) + }, [messages]) + + if (lastMessage) { + return null + } + + if (loadingSuggestions) { + return ( + + + + ) + } + + if (suggestions.length === 0) { + return null + } + + return ( + + + {suggestions.map((s, i) => ( + onClick(s)}> + {s.content} → + + ))} + + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + padding: 20px; + display: flex; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + gap: 15px; + padding-left: 55px; +` + +const SuggestionsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 15px; +` + +const SuggestionItem = styled.div` + display: flex; + align-items: center; + width: fit-content; + padding: 7px 15px; + border-radius: 12px; + font-size: 13px; + color: var(--color-text); + background: var(--color-background-mute); + cursor: pointer; + &:hover { + opacity: 0.9; + } +` + +export default Suggestions diff --git a/src/renderer/src/pages/settings/components/EditModelsPopup.tsx b/src/renderer/src/pages/settings/components/EditModelsPopup.tsx index ab505c61..6b82b491 100644 --- a/src/renderer/src/pages/settings/components/EditModelsPopup.tsx +++ b/src/renderer/src/pages/settings/components/EditModelsPopup.tsx @@ -71,7 +71,8 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { provider: _provider.id, group: getDefaultGroupName(model.id), // @ts-ignore name - description: model?.description + description: model?.description, + owned_by: model?.owned_by })) ) setLoading(false) diff --git a/src/renderer/src/services/ProviderSDK.ts b/src/renderer/src/services/ProviderSDK.ts index dfb77319..d016f56d 100644 --- a/src/renderer/src/services/ProviderSDK.ts +++ b/src/renderer/src/services/ProviderSDK.ts @@ -1,4 +1,4 @@ -import { Assistant, Message, Provider } from '@renderer/types' +import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import OpenAI from 'openai' import Anthropic from '@anthropic-ai/sdk' import { getDefaultModel, getTopNamingModel } from './assistant' @@ -134,6 +134,28 @@ export default class ProviderSDK { } } + public async suggestions(messages: Message[], assistant: Assistant): Promise { + const model = assistant.model + + if (!model) { + return [] + } + + const response: any = await this.openaiSdk.request({ + method: 'post', + path: '/advice_questions', + body: { + messages: messages.filter((m) => m.role === 'user').map((m) => ({ role: m.role, content: m.content })), + model: model.id, + max_tokens: 0, + temperature: 0, + n: 0 + } + }) + + return response?.questions?.map((q: any) => ({ content: q })) || [] + } + public async check(): Promise<{ valid: boolean; error: Error | null }> { const model = this.provider.models[0] const body = { diff --git a/src/renderer/src/services/api.ts b/src/renderer/src/services/api.ts index 03153cee..0483c1e4 100644 --- a/src/renderer/src/services/api.ts +++ b/src/renderer/src/services/api.ts @@ -1,7 +1,7 @@ import i18n from '@renderer/i18n' import store from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' -import { Assistant, Message, Provider, Topic } from '@renderer/types' +import { Assistant, Message, Provider, Suggestion, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { @@ -109,6 +109,34 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages: } } +export async function fetchSuggestions({ + messages, + assistant +}: { + messages: Message[] + assistant: Assistant +}): Promise { + console.debug('fetchSuggestions', messages, assistant) + const provider = getAssistantProvider(assistant) + const providerSdk = new ProviderSDK(provider) + console.debug('fetchSuggestions', provider) + const model = assistant.model + + if (!model) { + return [] + } + + if (model.owned_by !== 'graphrag') { + return [] + } + + try { + return await providerSdk.suggestions(messages, assistant) + } catch (error: any) { + return [] + } +} + export async function checkApi(provider: Provider) { const model = provider.models[0] const key = 'api-check' diff --git a/src/renderer/src/services/assistant.ts b/src/renderer/src/services/assistant.ts index a0d4bfc3..aa370c22 100644 --- a/src/renderer/src/services/assistant.ts +++ b/src/renderer/src/services/assistant.ts @@ -36,7 +36,7 @@ export function getTranslateModel() { return store.getState().llm.translateModel } -export function getAssistantProvider(assistant: Assistant) { +export function getAssistantProvider(assistant: Assistant): Provider { const providers = store.getState().llm.providers const provider = providers.find((p) => p.id === assistant.model?.provider) return provider || getDefaultProvider() diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index ac804885..51796924 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -55,6 +55,7 @@ export type Model = { provider: string name: string group: string + owned_by?: string description?: string } @@ -66,3 +67,7 @@ export type SystemAssistant = { prompt: string group: string } + +export type Suggestion = { + content: string +} diff --git a/yarn.lock b/yarn.lock index 74e5a372..9b5b5e8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3479,6 +3479,7 @@ __metadata: react-redux: "npm:^9.1.2" react-router: "npm:6" react-router-dom: "npm:6" + react-spinners: "npm:^0.14.1" react-syntax-highlighter: "npm:^15.5.0" redux-persist: "npm:^6.0.0" sass: "npm:^1.77.2" @@ -8601,6 +8602,16 @@ __metadata: languageName: node linkType: hard +"react-spinners@npm:^0.14.1": + version: 0.14.1 + resolution: "react-spinners@npm:0.14.1" + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/5b3c101f789716331a0b6afad156293fb9aa05620e65494753001afcdb611788057f379b5979b34d570d527fa978003293266b59db505bf2d243ebab899ceeda + languageName: node + linkType: hard + "react-syntax-highlighter@npm:^15.5.0": version: 15.5.0 resolution: "react-syntax-highlighter@npm:15.5.0"