diff --git a/backend-python/routes/completion.py b/backend-python/routes/completion.py index fa1ebd1..2d6d587 100644 --- a/backend-python/routes/completion.py +++ b/backend-python/routes/completion.py @@ -56,7 +56,7 @@ async def completions(body: CompletionBody, request: Request): set_rwkv_config(model, body) if body.stream: for response, delta in rwkv_generate( - model, completion_text, stop="Bob:" + model, completion_text, stop="\n\nBob" ): if await request.is_disconnected(): break @@ -90,7 +90,7 @@ async def completions(body: CompletionBody, request: Request): else: response = None for response, delta in rwkv_generate( - model, completion_text, stop="Bob:" + model, completion_text, stop="\n\nBob" ): pass yield { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index df8c3c6..c68c01e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@fluentui/react-components": "^9.20.0", "@fluentui/react-icons": "^2.0.201", + "@microsoft/fetch-event-source": "^2.0.1", + "classnames": "^2.3.2", "i18next": "^22.4.15", "mobx": "^6.9.0", "mobx-react-lite": "^3.4.3", @@ -19,11 +21,13 @@ "react-router": "^6.11.1", "react-router-dom": "^6.11.1", "react-toastify": "^9.1.3", - "usehooks-ts": "^2.9.1" + "usehooks-ts": "^2.9.1", + "uuid": "^9.0.0" }, "devDependencies": { "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "@types/uuid": "^9.0.1", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.23", @@ -1885,6 +1889,11 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1964,6 +1973,12 @@ "resolved": "https://registry.npmmirror.com/@types/scheduler/-/scheduler-0.16.3.tgz", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, + "node_modules/@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.0.0.tgz", @@ -2168,6 +2183,11 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", @@ -3505,6 +3525,14 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "4.3.6", "resolved": "https://registry.npmmirror.com/vite/-/vite-4.3.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index ca00b44..42173a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,8 @@ "dependencies": { "@fluentui/react-components": "^9.20.0", "@fluentui/react-icons": "^2.0.201", + "@microsoft/fetch-event-source": "^2.0.1", + "classnames": "^2.3.2", "i18next": "^22.4.15", "mobx": "^6.9.0", "mobx-react-lite": "^3.4.3", @@ -20,11 +22,13 @@ "react-router": "^6.11.1", "react-router-dom": "^6.11.1", "react-toastify": "^9.1.3", - "usehooks-ts": "^2.9.1" + "usehooks-ts": "^2.9.1", + "uuid": "^9.0.0" }, "devDependencies": { "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "@types/uuid": "^9.0.1", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.23", diff --git a/frontend/src/_locales/zh-hans/main.json b/frontend/src/_locales/zh-hans/main.json index b20c438..f59006c 100644 --- a/frontend/src/_locales/zh-hans/main.json +++ b/frontend/src/_locales/zh-hans/main.json @@ -63,5 +63,8 @@ "Convert Success": "转换成功", "Convert Failed": "转换失败", "Model Not Found": "模型不存在", - "Model Status": "模型状态" + "Model Status": "模型状态", + "Clear": "清除", + "Send": "发送", + "Type your message here": "在此输入消息" } \ No newline at end of file diff --git a/frontend/src/components/Labeled.tsx b/frontend/src/components/Labeled.tsx index 6bb164e..3548e7f 100644 --- a/frontend/src/components/Labeled.tsx +++ b/frontend/src/components/Labeled.tsx @@ -1,5 +1,6 @@ import {FC, ReactElement} from 'react'; import {Label, Tooltip} from '@fluentui/react-components'; +import classnames from 'classnames'; export const Labeled: FC<{ label: string; desc?: string, content: ReactElement, flex?: boolean, spaceBetween?: boolean @@ -11,10 +12,10 @@ export const Labeled: FC<{ spaceBetween }) => { return ( -
{desc ? diff --git a/frontend/src/components/NumberInput.tsx b/frontend/src/components/NumberInput.tsx index 3a76772..c5d99da 100644 --- a/frontend/src/components/NumberInput.tsx +++ b/frontend/src/components/NumberInput.tsx @@ -1,5 +1,4 @@ -import React, * as React_2 from 'react'; -import {CSSProperties, FC} from 'react'; +import React, {CSSProperties, FC} from 'react'; import {Input} from '@fluentui/react-components'; import {SliderOnChangeData} from '@fluentui/react-slider'; @@ -8,7 +7,7 @@ export const NumberInput: FC<{ min: number, max: number, step?: number, - onChange?: (ev: React_2.ChangeEvent, data: SliderOnChangeData) => void + onChange?: (ev: React.ChangeEvent, data: SliderOnChangeData) => void style?: CSSProperties }> = ({value, min, max, step, onChange, style}) => { return ( diff --git a/frontend/src/components/ValuedSlider.tsx b/frontend/src/components/ValuedSlider.tsx index 71c0654..2d0f337 100644 --- a/frontend/src/components/ValuedSlider.tsx +++ b/frontend/src/components/ValuedSlider.tsx @@ -1,5 +1,4 @@ -import React, * as React_2 from 'react'; -import {FC, useEffect, useRef} from 'react'; +import React, {FC, useEffect, useRef} from 'react'; import {Slider, Text} from '@fluentui/react-components'; import {SliderOnChangeData} from '@fluentui/react-slider'; import {NumberInput} from './NumberInput'; @@ -10,7 +9,7 @@ export const ValuedSlider: FC<{ max: number, step?: number, input?: boolean - onChange?: (ev: React_2.ChangeEvent, data: SliderOnChangeData) => void + onChange?: (ev: React.ChangeEvent, data: SliderOnChangeData) => void }> = ({value, min, max, step, input, onChange}) => { const sliderRef = useRef(null); useEffect(() => { diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 4e85c9d..5a0f736 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -1,17 +1,213 @@ -import React, {FC} from 'react'; +import React, {FC, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {RunButton} from '../components/RunButton'; -import {Divider, PresenceBadge, Text} from '@fluentui/react-components'; +import {Avatar, Divider, Input, PresenceBadge, Text} from '@fluentui/react-components'; import commonStore, {ModelStatus} from '../stores/commonStore'; import {observer} from 'mobx-react-lite'; import {PresenceBadgeStatus} from '@fluentui/react-badge'; import {ConfigSelector} from '../components/ConfigSelector'; +import {v4 as uuid} from 'uuid'; +import classnames from 'classnames'; +import {fetchEventSource} from '@microsoft/fetch-event-source'; +import {ConversationPair, getConversationPairs, Record} from '../utils/get-conversation-pairs'; +import logo from '../../../build/appicon.png'; + +const userName = 'M E'; +const botName = 'A I'; + +enum MessageType { + Normal, + Error +} + +type Side = 'left' | 'right' + +type Color = 'neutral' | 'brand' | 'colorful' + +type MessageItem = { + sender: string, + type: MessageType, + color: Color, + avatarImg?: string, + time: string, + content: string, + side: Side, + done: boolean +} + +type Conversations = { + [uuid: string]: MessageItem +} + +const ChatPanel: FC = observer(() => { + const {t} = useTranslation(); + const [message, setMessage] = useState(''); + const [conversations, setConversations] = useState({}); + const [conversationsOrder, setConversationsOrder] = useState([]); + const bodyRef = useRef(null); + const port = commonStore.getCurrentModelConfig().apiParameters.apiPort; + + const scrollToBottom = () => { + if (bodyRef.current) + bodyRef.current.scrollTop = bodyRef.current.scrollHeight; + }; + + const handleSubmit = (e: React.ChangeEvent) => { + e.preventDefault(); + if (message !== '') { + setMessage(''); + const newId = uuid(); + conversations[newId] = { + sender: userName, + type: MessageType.Normal, + color: 'brand', + time: new Date().toISOString(), + content: message, + side: 'right', + done: true + }; + setConversations(conversations); + conversationsOrder.push(newId); + setConversationsOrder(conversationsOrder); + + const records: Record[] = []; + conversationsOrder.forEach((uuid, index) => { + const conversation = conversations[uuid]; + if (conversation.done && conversation.type === MessageType.Normal && conversation.sender === botName) { + if (index > 0) { + const questionId = conversationsOrder[index - 1]; + const question = conversations[questionId]; + if (question.done && question.type === MessageType.Normal && question.sender === userName) { + records.push({question: question.content, answer: conversation.content}); + } + } + } + }); + const messages = getConversationPairs(records, false); + (messages as ConversationPair[]).push({role: 'user', content: message}); + + const answerId = uuid(); + conversations[answerId] = { + sender: botName, + type: MessageType.Normal, + color: 'colorful', + avatarImg: logo, + time: new Date().toISOString(), + content: '', + side: 'left', + done: false + }; + setConversations(conversations); + conversationsOrder.push(answerId); + setConversationsOrder(conversationsOrder); + setTimeout(scrollToBottom); + let answer = ''; + fetchEventSource(`http://127.0.0.1:${port}/chat/completions`, // https://api.openai.com/v1/chat/completions || http://127.0.0.1:${port}/chat/completions + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer sk-` + }, + body: JSON.stringify({ + messages, + stream: true, + model: 'gpt-3.5-turbo' + }), + onmessage(e) { + console.log('sse message', e); + scrollToBottom(); + if (e.data === '[DONE]') { + conversations[answerId].done = true; + setConversations(conversations); + setConversationsOrder([...conversationsOrder]); + return; + } + let data; + try { + data = JSON.parse(e.data); + } catch (error) { + console.debug('json error', error); + return; + } + if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) { + answer += data.choices[0]?.delta?.content || ''; + conversations[answerId].content = answer; + setConversations(conversations); + setConversationsOrder([...conversationsOrder]); + } + }, + onclose() { + console.log('Connection closed'); + }, + onerror(err) { + conversations[answerId].type = MessageType.Error; + conversations[answerId].done = true; + setConversations(conversations); + setConversationsOrder([...conversationsOrder]); + throw err; + } + }); + } + }; -const ChatPanel: FC = () => { return ( -
+
+
+ {conversationsOrder.map((uuid, index) => { + const conversation = conversations[uuid]; + return
+ +
+ {conversation.content} +
+ {(conversation.type === MessageType.Error || !conversation.done) && + + } +
; + })} +
+
+ +
+ setMessage(e.target.value)} + /> + +
+
+
); -}; +}); const statusText = { [ModelStatus.Offline]: 'Offline', diff --git a/frontend/src/utils/get-conversation-pairs.ts b/frontend/src/utils/get-conversation-pairs.ts new file mode 100644 index 0000000..05cf570 --- /dev/null +++ b/frontend/src/utils/get-conversation-pairs.ts @@ -0,0 +1,27 @@ +export type Record = { + question: string; + answer: string; +} + +export type ConversationPair = { + role: string; + content: string; +} + +export function getConversationPairs(records: Record[], isCompletion: boolean): string | ConversationPair[] { + let pairs; + if (isCompletion) { + pairs = ''; + for (const record of records) { + pairs += 'Human: ' + record.question + '\nAI: ' + record.answer + '\n'; + } + } else { + pairs = []; + for (const record of records) { + pairs.push({role: 'user', content: record.question}); + pairs.push({role: 'assistant', content: record.answer}); + } + } + + return pairs; +}