feat: add message suggestions

This commit is contained in:
kangfenmao 2024-07-31 12:13:03 +08:00
parent c50ff4585a
commit d8d4afbc0d
11 changed files with 221 additions and 9 deletions

View File

@ -70,6 +70,7 @@
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-router": "6", "react-router": "6",
"react-router-dom": "6", "react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"sass": "^1.77.2", "sass": "^1.77.2",

View File

@ -151,3 +151,15 @@ body,
padding-bottom: 12px; 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;
}

View File

@ -78,7 +78,8 @@ const resources = {
'settings.conext_count.tip': 'The number of previous messages to keep in the context.', 'settings.conext_count.tip': 'The number of previous messages to keep in the context.',
'settings.reset': 'Reset', 'settings.reset': 'Reset',
'settings.set_as_default': 'Apply to default assistant', 'settings.set_as_default': 'Apply to default assistant',
'settings.max': 'Max' 'settings.max': 'Max',
'suggestions.title': 'Suggested Questions'
}, },
apps: { apps: {
title: 'Agents' title: 'Agents'
@ -262,7 +263,8 @@ const resources = {
'要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10代码生成建议 5-10', '要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10代码生成建议 5-10',
'settings.reset': '重置', 'settings.reset': '重置',
'settings.set_as_default': '应用到默认助手', 'settings.set_as_default': '应用到默认助手',
'settings.max': '不限' 'settings.max': '不限',
'suggestions.title': '建议的问题'
}, },
apps: { apps: {
title: '智能体' title: '智能体'

View File

@ -4,13 +4,14 @@ import localforage from 'localforage'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import MessageItem from './Message' import MessageItem from './Message'
import { reverse } from 'lodash' import { debounce, reverse } from 'lodash'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api' import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { estimateHistoryTokenCount, runAsyncFunction } from '@renderer/utils' import { estimateHistoryTokenCount, runAsyncFunction } from '@renderer/utils'
import LocalStorage from '@renderer/services/storage' import LocalStorage from '@renderer/services/storage'
import { useProviderByAssistant } from '@renderer/hooks/useProvider' import { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { t } from 'i18next' import { t } from 'i18next'
import Suggestions from './Suggestions'
interface Props { interface Props {
assistant: Assistant assistant: Assistant
@ -93,9 +94,18 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
}) })
}, [topic.id]) }, [topic.id])
const scrollTop = useCallback(
debounce(() => containerRef.current?.scrollTo({ top: 100000, behavior: 'auto' }), 500, {
leading: true,
trailing: false
}),
[]
)
useEffect(() => { useEffect(() => {
containerRef.current?.scrollTo({ top: 100000, behavior: 'auto' }) setTimeout(scrollTop, 100)
}, [messages]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, lastMessage])
useEffect(() => { useEffect(() => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, estimateHistoryTokenCount(assistant, messages)) EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, estimateHistoryTokenCount(assistant, messages))
@ -103,6 +113,7 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
return ( return (
<Container id="messages" key={assistant.id} ref={containerRef}> <Container id="messages" key={assistant.id} ref={containerRef}>
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
{lastMessage && <MessageItem message={lastMessage} />} {lastMessage && <MessageItem message={lastMessage} />}
{reverse([...messages]).map((message, index) => ( {reverse([...messages]).map((message, index) => (
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} /> <MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />

View File

@ -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<string, Suggestion[]>()
const Suggestions: FC<Props> = ({ assistant, messages, lastMessage }) => {
const [suggestions, setSuggestions] = useState<Suggestion[]>(
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 (
<Container>
<BeatLoader color="var(--color-text-2)" size="10" />
</Container>
)
}
if (suggestions.length === 0) {
return null
}
return (
<Container>
<SuggestionsContainer>
{suggestions.map((s, i) => (
<SuggestionItem key={i} onClick={() => onClick(s)}>
{s.content}
</SuggestionItem>
))}
</SuggestionsContainer>
</Container>
)
}
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

View File

@ -71,7 +71,8 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
provider: _provider.id, provider: _provider.id,
group: getDefaultGroupName(model.id), group: getDefaultGroupName(model.id),
// @ts-ignore name // @ts-ignore name
description: model?.description description: model?.description,
owned_by: model?.owned_by
})) }))
) )
setLoading(false) setLoading(false)

View File

@ -1,4 +1,4 @@
import { Assistant, Message, Provider } from '@renderer/types' import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import OpenAI from 'openai' import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk' import Anthropic from '@anthropic-ai/sdk'
import { getDefaultModel, getTopNamingModel } from './assistant' import { getDefaultModel, getTopNamingModel } from './assistant'
@ -134,6 +134,28 @@ export default class ProviderSDK {
} }
} }
public async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
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 }> { public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0] const model = this.provider.models[0]
const body = { const body = {

View File

@ -1,7 +1,7 @@
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' 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 { uuid } from '@renderer/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
@ -109,6 +109,34 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
} }
} }
export async function fetchSuggestions({
messages,
assistant
}: {
messages: Message[]
assistant: Assistant
}): Promise<Suggestion[]> {
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) { export async function checkApi(provider: Provider) {
const model = provider.models[0] const model = provider.models[0]
const key = 'api-check' const key = 'api-check'

View File

@ -36,7 +36,7 @@ export function getTranslateModel() {
return store.getState().llm.translateModel return store.getState().llm.translateModel
} }
export function getAssistantProvider(assistant: Assistant) { export function getAssistantProvider(assistant: Assistant): Provider {
const providers = store.getState().llm.providers const providers = store.getState().llm.providers
const provider = providers.find((p) => p.id === assistant.model?.provider) const provider = providers.find((p) => p.id === assistant.model?.provider)
return provider || getDefaultProvider() return provider || getDefaultProvider()

View File

@ -55,6 +55,7 @@ export type Model = {
provider: string provider: string
name: string name: string
group: string group: string
owned_by?: string
description?: string description?: string
} }
@ -66,3 +67,7 @@ export type SystemAssistant = {
prompt: string prompt: string
group: string group: string
} }
export type Suggestion = {
content: string
}

View File

@ -3479,6 +3479,7 @@ __metadata:
react-redux: "npm:^9.1.2" react-redux: "npm:^9.1.2"
react-router: "npm:6" react-router: "npm:6"
react-router-dom: "npm:6" react-router-dom: "npm:6"
react-spinners: "npm:^0.14.1"
react-syntax-highlighter: "npm:^15.5.0" react-syntax-highlighter: "npm:^15.5.0"
redux-persist: "npm:^6.0.0" redux-persist: "npm:^6.0.0"
sass: "npm:^1.77.2" sass: "npm:^1.77.2"
@ -8601,6 +8602,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react-syntax-highlighter@npm:^15.5.0":
version: 15.5.0 version: 15.5.0
resolution: "react-syntax-highlighter@npm:15.5.0" resolution: "react-syntax-highlighter@npm:15.5.0"