RWKV-Runner/frontend/src/pages/Chat.tsx

446 lines
17 KiB
TypeScript
Raw Normal View History

import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
2023-05-21 05:48:11 +00:00
import { useTranslation } from 'react-i18next';
import { Avatar, Button, Menu, MenuPopover, MenuTrigger, PresenceBadge, Textarea } from '@fluentui/react-components';
2023-05-21 05:48:11 +00:00
import commonStore, { ModelStatus } from '../stores/commonStore';
import { observer } from 'mobx-react-lite';
import { v4 as uuid } from 'uuid';
2023-05-19 06:22:37 +00:00
import classnames from 'classnames';
2023-05-21 05:48:11 +00:00
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { KebabHorizontalIcon, PencilIcon, SyncIcon, TrashIcon } from '@primer/octicons-react';
2023-07-09 03:59:23 +00:00
import logo from '../assets/images/logo.png';
2023-05-19 07:40:17 +00:00
import MarkdownRender from '../components/MarkdownRender';
2023-05-21 05:48:11 +00:00
import { ToolTipButton } from '../components/ToolTipButton';
2023-06-15 16:12:13 +00:00
import { ArrowCircleUp28Regular, Delete28Regular, RecordStop28Regular, Save28Regular } from '@fluentui/react-icons';
2023-05-21 05:48:11 +00:00
import { CopyButton } from '../components/CopyButton';
import { ReadButton } from '../components/ReadButton';
import { toast } from 'react-toastify';
2023-05-24 13:27:23 +00:00
import { WorkHeader } from '../components/WorkHeader';
2023-06-15 14:55:38 +00:00
import { DialogButton } from '../components/DialogButton';
2023-06-15 16:12:13 +00:00
import { OpenFileFolder, OpenSaveFileDialog } from '../../wailsjs/go/backend_golang/App';
import { toastWithButton } from '../utils';
2023-06-24 16:07:14 +00:00
import { PresetsButton } from './PresetsManager/PresetsButton';
import { useMediaQuery } from 'usehooks-ts';
2023-05-19 06:22:37 +00:00
2023-05-20 08:07:08 +00:00
export const userName = 'M E';
export const botName = 'A I';
2023-05-19 06:22:37 +00:00
export const welcomeUuid = 'welcome';
2023-05-20 08:07:08 +00:00
export enum MessageType {
2023-05-19 06:22:37 +00:00
Normal,
Error
}
2023-05-20 08:07:08 +00:00
export type Side = 'left' | 'right'
2023-05-19 06:22:37 +00:00
2023-05-20 08:07:08 +00:00
export type Color = 'neutral' | 'brand' | 'colorful'
2023-05-19 06:22:37 +00:00
2023-05-20 08:07:08 +00:00
export type MessageItem = {
2023-05-19 06:22:37 +00:00
sender: string,
type: MessageType,
color: Color,
avatarImg?: string,
time: string,
content: string,
side: Side,
done: boolean
}
2023-06-15 16:12:13 +00:00
export type Conversation = {
2023-05-19 06:22:37 +00:00
[uuid: string]: MessageItem
}
2023-06-24 16:07:14 +00:00
export type Role = 'assistant' | 'user' | 'system';
export type ConversationMessage = {
role: Role;
content: string;
}
2023-06-14 12:06:19 +00:00
let chatSseController: AbortController | null = null;
const MoreUtilsButton: FC<{ uuid: string, setEditing: (editing: boolean) => void }> = observer(({
uuid,
setEditing
}) => {
const { t } = useTranslation();
const [speaking, setSpeaking] = useState(false);
const messageItem = commonStore.conversation[uuid];
return <Menu>
<MenuTrigger disableButtonEnhancement>
<Button icon={<KebabHorizontalIcon />} size="small" appearance="subtle" />
</MenuTrigger>
<MenuPopover style={{ minWidth: 0 }}>
2023-06-21 15:11:22 +00:00
<CopyButton content={messageItem.content} showDelay={500} />
<ReadButton content={messageItem.content} inSpeaking={speaking} showDelay={500} setSpeakingOuter={setSpeaking} />
<ToolTipButton desc={t('Edit')} icon={<PencilIcon />} showDelay={500} size="small" appearance="subtle"
onClick={() => {
setEditing(true);
}} />
<ToolTipButton desc={t('Delete')} icon={<TrashIcon />} showDelay={500} size="small" appearance="subtle"
onClick={() => {
commonStore.conversationOrder.splice(commonStore.conversationOrder.indexOf(uuid), 1);
delete commonStore.conversation[uuid];
}} />
</MenuPopover>
</Menu>;
});
const ChatMessageItem: FC<{
uuid: string, onSubmit: (message: string | null, answerId: string | null,
startUuid: string | null, endUuid: string | null, includeEndUuid: boolean) => void
}> = observer(({ uuid, onSubmit }) => {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageItem = commonStore.conversation[uuid];
console.log(uuid);
const setEditingInner = (editing: boolean) => {
setEditing(editing);
if (editing) {
setTimeout(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
textarea.style.height = textarea.scrollHeight + 'px';
}
});
}
};
return <div
className={classnames(
'flex gap-2 mb-2 overflow-hidden',
messageItem.side === 'left' ? 'flex-row' : 'flex-row-reverse'
)}
onMouseEnter={() => {
const utils = document.getElementById('utils-' + uuid);
if (utils) utils.classList.remove('invisible');
}}
onMouseLeave={() => {
const utils = document.getElementById('utils-' + uuid);
if (utils) utils.classList.add('invisible');
}}
>
<Avatar
color={messageItem.color}
name={messageItem.sender}
2023-06-24 16:07:14 +00:00
image={(commonStore.activePreset && messageItem.sender === botName) ? { src: commonStore.activePreset.avatarImg } : messageItem.avatarImg ? { src: messageItem.avatarImg } : undefined}
/>
<div
className={classnames(
'flex p-2 rounded-lg overflow-hidden',
editing ? 'grow' : '',
messageItem.side === 'left' ? 'bg-gray-200' : 'bg-blue-500',
messageItem.side === 'left' ? 'text-gray-600' : 'text-white'
)}
>
{!editing ?
<MarkdownRender>{messageItem.content}</MarkdownRender> :
<Textarea ref={textareaRef}
className="grow"
style={{ minWidth: 0 }}
value={messageItem.content}
onChange={(e) => {
messageItem.content = e.target.value;
}}
onBlur={() => {
setEditingInner(false);
}} />}
</div>
<div className="flex flex-col gap-1 items-start">
<div className="grow" />
{(messageItem.type === MessageType.Error || !messageItem.done) &&
<PresenceBadge size="extra-small" status={
messageItem.type === MessageType.Error ? 'busy' : 'away'
} />
}
<div className="flex invisible" id={'utils-' + uuid}>
{
messageItem.sender === botName && uuid !== welcomeUuid &&
<ToolTipButton desc={t('Retry')} size="small" appearance="subtle"
icon={<SyncIcon />} onClick={() => {
onSubmit(null, uuid, null, uuid, false);
}} />
}
2023-06-21 15:11:22 +00:00
<ToolTipButton desc={t('Edit')} icon={<PencilIcon />} size="small" appearance="subtle"
onClick={() => {
setEditingInner(true);
}} />
<MoreUtilsButton uuid={uuid} setEditing={setEditingInner} />
</div>
</div>
</div>;
});
2023-05-19 06:22:37 +00:00
const ChatPanel: FC = observer(() => {
2023-05-21 05:48:11 +00:00
const { t } = useTranslation();
2023-05-19 06:22:37 +00:00
const bodyRef = useRef<HTMLDivElement>(null);
2023-05-19 07:40:17 +00:00
const inputRef = useRef<HTMLTextAreaElement>(null);
2023-06-24 16:07:14 +00:00
const mq = useMediaQuery('(min-width: 640px)');
2023-05-19 06:22:37 +00:00
const port = commonStore.getCurrentModelConfig().apiParameters.apiPort;
2023-05-19 12:10:30 +00:00
let lastMessageId: string;
let generating: boolean = false;
2023-06-15 16:12:13 +00:00
if (commonStore.conversationOrder.length > 0) {
lastMessageId = commonStore.conversationOrder[commonStore.conversationOrder.length - 1];
const lastMessage = commonStore.conversation[lastMessageId];
2023-05-19 12:10:30 +00:00
if (lastMessage.sender === botName)
generating = !lastMessage.done;
}
2023-05-19 06:22:37 +00:00
2023-05-19 07:40:17 +00:00
useEffect(() => {
if (inputRef.current)
inputRef.current.style.maxHeight = '16rem';
2023-05-23 04:24:39 +00:00
scrollToBottom();
2023-05-19 07:40:17 +00:00
}, []);
2023-05-20 08:07:08 +00:00
useEffect(() => {
2023-06-15 16:12:13 +00:00
if (commonStore.conversationOrder.length === 0) {
commonStore.setConversationOrder([welcomeUuid]);
2023-06-15 16:12:13 +00:00
commonStore.setConversation({
[welcomeUuid]: {
2023-05-20 08:07:08 +00:00
sender: botName,
type: MessageType.Normal,
color: 'colorful',
avatarImg: logo,
time: new Date().toISOString(),
2023-06-17 11:32:47 +00:00
content: t('Hello! I\'m RWKV, an open-source and commercially usable large language model.'),
2023-05-20 08:07:08 +00:00
side: 'left',
done: true
}
});
}
}, []);
2023-05-19 06:22:37 +00:00
const scrollToBottom = () => {
if (bodyRef.current)
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
};
2023-05-19 12:10:30 +00:00
const handleKeyDownOrClick = (e: any) => {
e.stopPropagation();
if (e.type === 'click' || (e.keyCode === 13 && !e.shiftKey)) {
e.preventDefault();
2023-06-20 16:26:50 +00:00
if (commonStore.status.status === ModelStatus.Offline && !commonStore.settings.apiUrl) {
2023-05-21 05:48:11 +00:00
toast(t('Please click the button in the top right corner to start the model'), { type: 'warning' });
2023-05-20 02:38:35 +00:00
return;
}
2023-06-14 12:26:04 +00:00
if (!commonStore.currentInput) return;
onSubmit(commonStore.currentInput);
commonStore.setCurrentInput('');
2023-05-19 12:10:30 +00:00
}
};
// if message is not null, create a user message;
// if answerId is not null, override the answer with new response;
// if startUuid is null, start generating api body messages from first message;
// if endUuid is null, generate api body messages until last message;
const onSubmit = useCallback((message: string | null = null, answerId: string | null = null,
startUuid: string | null = null, endUuid: string | null = null, includeEndUuid: boolean = false) => {
if (message) {
const newId = uuid();
commonStore.conversation[newId] = {
sender: userName,
type: MessageType.Normal,
color: 'brand',
time: new Date().toISOString(),
content: message,
side: 'right',
done: true
};
commonStore.setConversation(commonStore.conversation);
commonStore.conversationOrder.push(newId);
commonStore.setConversationOrder(commonStore.conversationOrder);
}
let startIndex = startUuid ? commonStore.conversationOrder.indexOf(startUuid) : 0;
let endIndex = endUuid ? (commonStore.conversationOrder.indexOf(endUuid) + (includeEndUuid ? 1 : 0)) : commonStore.conversationOrder.length;
let targetRange = commonStore.conversationOrder.slice(startIndex, endIndex);
2023-05-19 12:10:30 +00:00
2023-06-24 16:07:14 +00:00
const messages: ConversationMessage[] = [];
targetRange.forEach((uuid, index) => {
if (uuid === welcomeUuid)
return;
2023-06-15 16:12:13 +00:00
const messageItem = commonStore.conversation[uuid];
if (messageItem.done && messageItem.type === MessageType.Normal && messageItem.sender === userName) {
messages.push({ role: 'user', content: messageItem.content });
} else if (messageItem.done && messageItem.type === MessageType.Normal && messageItem.sender === botName) {
messages.push({ role: 'assistant', content: messageItem.content });
2023-05-19 12:10:30 +00:00
}
});
if (answerId === null) {
answerId = uuid();
commonStore.conversationOrder.push(answerId);
}
2023-06-15 16:12:13 +00:00
commonStore.conversation[answerId] = {
2023-05-19 12:10:30 +00:00
sender: botName,
type: MessageType.Normal,
color: 'colorful',
avatarImg: logo,
time: new Date().toISOString(),
content: '',
side: 'left',
done: false
};
2023-06-15 16:12:13 +00:00
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder(commonStore.conversationOrder);
2023-05-19 12:10:30 +00:00
setTimeout(scrollToBottom);
let answer = '';
2023-06-14 12:06:19 +00:00
chatSseController = new AbortController();
2023-06-20 15:24:51 +00:00
fetchEventSource( // https://api.openai.com/v1/chat/completions || http://127.0.0.1:${port}/chat/completions
commonStore.settings.apiUrl ?
commonStore.settings.apiUrl + '/v1/chat/completions' :
`http://127.0.0.1:${port}/chat/completions`,
2023-05-19 12:10:30 +00:00
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
2023-06-20 15:24:51 +00:00
Authorization: `Bearer ${commonStore.settings.apiKey}`
2023-05-19 12:10:30 +00:00
},
body: JSON.stringify({
messages,
stream: true,
2023-06-20 15:24:51 +00:00
model: commonStore.settings.apiChatModelName // 'gpt-3.5-turbo'
2023-05-19 12:10:30 +00:00
}),
2023-06-14 12:06:19 +00:00
signal: chatSseController?.signal,
2023-05-19 12:10:30 +00:00
onmessage(e) {
scrollToBottom();
if (e.data === '[DONE]') {
commonStore.conversation[answerId!].done = true;
commonStore.conversation[answerId!].content = commonStore.conversation[answerId!].content.trim();
2023-06-15 16:12:13 +00:00
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]);
2023-05-19 12:10:30 +00:00
return;
2023-05-19 06:22:37 +00:00
}
2023-05-19 12:10:30 +00:00
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 || '';
commonStore.conversation[answerId!].content = answer;
2023-06-15 16:12:13 +00:00
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]);
2023-05-19 12:10:30 +00:00
}
},
2023-06-20 16:26:50 +00:00
async onopen(response) {
if (response.status !== 200) {
commonStore.conversation[answerId!].content += '\n[ERROR]\n```\n' + response.statusText + '\n' + (await response.text()) + '\n```';
2023-06-20 16:26:50 +00:00
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]);
setTimeout(scrollToBottom);
}
},
2023-05-19 12:10:30 +00:00
onclose() {
console.log('Connection closed');
},
onerror(err) {
commonStore.conversation[answerId!].type = MessageType.Error;
commonStore.conversation[answerId!].done = true;
2023-06-20 16:26:50 +00:00
err = err.message || err;
if (err && !err.includes('ReadableStreamDefaultReader'))
commonStore.conversation[answerId!].content += '\n[ERROR]\n```\n' + err + '\n```';
2023-06-15 16:12:13 +00:00
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]);
2023-06-20 16:26:50 +00:00
setTimeout(scrollToBottom);
2023-05-19 12:10:30 +00:00
throw err;
}
});
}, []);
2023-05-05 15:23:34 +00:00
2023-05-19 02:08:28 +00:00
return (
2023-05-19 06:22:37 +00:00
<div className="flex flex-col w-full grow gap-4 pt-4 overflow-hidden">
<div ref={bodyRef} className="grow overflow-y-scroll overflow-x-hidden pr-2">
{commonStore.conversationOrder.map(uuid =>
<ChatMessageItem key={uuid} uuid={uuid} onSubmit={onSubmit} />
)}
2023-05-19 06:22:37 +00:00
</div>
2023-06-24 16:07:14 +00:00
<div className={classnames('flex items-end', mq ? 'gap-2' : '')}>
<PresetsButton tab="Chat" size={mq ? 'large' : 'small'} shape="circular" appearance="subtle" />
2023-06-15 14:55:38 +00:00
<DialogButton tooltip={t('Clear')}
2023-05-21 05:48:11 +00:00
icon={<Delete28Regular />}
2023-06-24 16:07:14 +00:00
size={mq ? 'large' : 'small'} shape="circular" appearance="subtle" title={t('Clear')}
2023-06-15 14:55:38 +00:00
contentText={t('Are you sure you want to clear the conversation? It cannot be undone.')}
onConfirm={() => {
2023-05-21 05:48:11 +00:00
if (generating)
2023-06-14 12:06:19 +00:00
chatSseController?.abort();
2023-06-15 16:12:13 +00:00
commonStore.setConversation({});
commonStore.setConversationOrder([]);
2023-06-15 14:55:38 +00:00
}} />
2023-05-19 12:10:30 +00:00
<Textarea
ref={inputRef}
2023-06-24 16:07:14 +00:00
style={{ minWidth: 0 }}
2023-05-19 12:10:30 +00:00
className="grow"
resize="vertical"
placeholder={t('Type your message here')!}
2023-06-14 12:26:04 +00:00
value={commonStore.currentInput}
onChange={(e) => commonStore.setCurrentInput(e.target.value)}
2023-05-19 12:10:30 +00:00
onKeyDown={handleKeyDownOrClick}
/>
<ToolTipButton desc={generating ? t('Stop') : t('Send')}
2023-05-21 05:48:11 +00:00
icon={generating ? <RecordStop28Regular /> : <ArrowCircleUp28Regular />}
2023-06-24 16:07:14 +00:00
size={mq ? 'large' : 'small'} shape="circular" appearance="subtle"
2023-05-21 05:48:11 +00:00
onClick={(e) => {
if (generating) {
2023-06-14 12:06:19 +00:00
chatSseController?.abort();
2023-05-21 05:48:11 +00:00
if (lastMessageId) {
2023-06-15 16:12:13 +00:00
commonStore.conversation[lastMessageId].type = MessageType.Error;
commonStore.conversation[lastMessageId].done = true;
commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]);
2023-05-21 05:48:11 +00:00
}
} else {
handleKeyDownOrClick(e);
}
}} />
2023-06-15 16:12:13 +00:00
<ToolTipButton desc={t('Save')}
icon={<Save28Regular />}
2023-06-24 16:07:14 +00:00
size={mq ? 'large' : 'small'} shape="circular" appearance="subtle"
2023-06-15 16:12:13 +00:00
onClick={() => {
let savedContent: string = '';
2023-06-28 12:48:22 +00:00
const isWorldModel = commonStore.getCurrentModelConfig().modelParameters.modelName.toLowerCase().includes('world');
const user = isWorldModel ? 'Question' : 'Bob';
const bot = isWorldModel ? 'Answer' : 'Alice';
2023-06-15 16:12:13 +00:00
commonStore.conversationOrder.forEach((uuid) => {
2023-06-28 12:48:22 +00:00
if (uuid === welcomeUuid)
return;
2023-06-15 16:12:13 +00:00
const messageItem = commonStore.conversation[uuid];
2023-06-28 12:48:22 +00:00
if (messageItem.type !== MessageType.Error) {
savedContent += `${messageItem.sender === userName ? user : bot}: ${messageItem.content}\n\n`;
}
2023-06-15 16:12:13 +00:00
});
OpenSaveFileDialog('*.md', 'conversation.md', savedContent).then((path) => {
if (path)
toastWithButton(t('Conversation Saved'), t('Open'), () => {
OpenFileFolder(path, false);
});
}).catch(e => {
2023-06-23 05:55:45 +00:00
toast(t('Error') + ' - ' + (e.message || e), { type: 'error', autoClose: 2500 });
2023-06-15 16:12:13 +00:00
});
}} />
2023-05-19 06:22:37 +00:00
</div>
</div>
2023-05-19 02:08:28 +00:00
);
2023-05-19 06:22:37 +00:00
});
2023-05-19 02:08:28 +00:00
export const Chat: FC = observer(() => {
2023-05-05 15:23:34 +00:00
return (
2023-05-19 02:08:28 +00:00
<div className="flex flex-col gap-1 p-2 h-full overflow-hidden">
2023-05-24 13:27:23 +00:00
<WorkHeader />
2023-05-21 05:48:11 +00:00
<ChatPanel />
2023-05-19 02:08:28 +00:00
</div>
2023-05-05 15:23:34 +00:00
);
2023-05-19 02:08:28 +00:00
});