add more chat utils (retry, edit, delete)

This commit is contained in:
josc146 2023-06-21 21:20:21 +08:00
parent 35a7437714
commit 4cd5a56070
5 changed files with 205 additions and 99 deletions

View File

@ -154,5 +154,8 @@
"Restart": "重启", "Restart": "重启",
"API Chat Model Name": "API聊天模型名", "API Chat Model Name": "API聊天模型名",
"API Completion Model Name": "API补全模型名", "API Completion Model Name": "API补全模型名",
"Localhost": "本地" "Localhost": "本地",
"Retry": "重试",
"Delete": "删除",
"Edit": "编辑"
} }

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ClipboardSetText } from '../../wailsjs/runtime'; import { ClipboardSetText } from '../../wailsjs/runtime';
import { ToolTipButton } from './ToolTipButton'; import { ToolTipButton } from './ToolTipButton';
export const CopyButton: FC<{ content: string }> = ({ content }) => { export const CopyButton: FC<{ content: string, showDelay?: number, }> = ({ content, showDelay = 0 }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -19,7 +19,8 @@ export const CopyButton: FC<{ content: string }> = ({ content }) => {
}; };
return ( return (
<ToolTipButton desc={t('Copy')} size="small" appearance="subtle" icon={copied ? <CheckIcon /> : <CopyIcon />} <ToolTipButton desc={t('Copy')} size="small" appearance="subtle" showDelay={showDelay}
icon={copied ? <CheckIcon /> : <CopyIcon />}
onClick={onClick} /> onClick={onClick} />
); );
}; };

View File

@ -7,13 +7,28 @@ import { observer } from 'mobx-react-lite';
const synth = window.speechSynthesis; const synth = window.speechSynthesis;
export const ReadButton: FC<{ content: string }> = observer(({ content }) => { export const ReadButton: FC<{
content: string,
inSpeaking?: boolean,
showDelay?: number,
setSpeakingOuter?: (speaking: boolean) => void
}> = observer(({
content,
inSpeaking = false,
showDelay = 0,
setSpeakingOuter
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [speaking, setSpeaking] = useState(false); const [speaking, setSpeaking] = useState(inSpeaking);
let lang: string = commonStore.settings.language; let lang: string = commonStore.settings.language;
if (lang === 'dev') if (lang === 'dev')
lang = 'en'; lang = 'en';
const setSpeakingInner = (speaking: boolean) => {
setSpeakingOuter?.(speaking);
setSpeaking(speaking);
};
const startSpeak = () => { const startSpeak = () => {
synth.cancel(); synth.cancel();
@ -31,22 +46,22 @@ export const ReadButton: FC<{ content: string }> = observer(({ content }) => {
Object.assign(utterance, { Object.assign(utterance, {
rate: 1, rate: 1,
volume: 1, volume: 1,
onend: () => setSpeaking(false), onend: () => setSpeakingInner(false),
onerror: () => setSpeaking(false), onerror: () => setSpeakingInner(false),
voice: voice voice: voice
}); });
synth.speak(utterance); synth.speak(utterance);
setSpeaking(true); setSpeakingInner(true);
}; };
const stopSpeak = () => { const stopSpeak = () => {
synth.cancel(); synth.cancel();
setSpeaking(false); setSpeakingInner(false);
}; };
return ( return (
<ToolTipButton desc={t('Read Aloud')} size="small" appearance="subtle" <ToolTipButton desc={t('Read Aloud')} size="small" appearance="subtle" showDelay={showDelay}
icon={speaking ? <MuteIcon /> : <UnmuteIcon />} icon={speaking ? <MuteIcon /> : <UnmuteIcon />}
onClick={speaking ? stopSpeak : startSpeak} /> onClick={speaking ? stopSpeak : startSpeak} />
); );

View File

@ -11,6 +11,7 @@ export const ToolTipButton: FC<{
appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent';
disabled?: boolean, disabled?: boolean,
onClick?: MouseEventHandler onClick?: MouseEventHandler
showDelay?: number,
}> = ({ }> = ({
text, text,
desc, desc,
@ -20,10 +21,11 @@ export const ToolTipButton: FC<{
shape, shape,
appearance, appearance,
disabled, disabled,
onClick onClick,
showDelay = 0
}) => { }) => {
return ( return (
<Tooltip content={desc} showDelay={0} hideDelay={0} relationship="label"> <Tooltip content={desc} showDelay={showDelay} hideDelay={0} relationship="label">
<Button className={className} disabled={disabled} icon={icon} onClick={onClick} size={size} shape={shape} <Button className={className} disabled={disabled} icon={icon} onClick={onClick} size={size} shape={shape}
appearance={appearance}>{text}</Button> appearance={appearance}>{text}</Button>
</Tooltip> </Tooltip>

View File

@ -1,12 +1,13 @@
import React, { FC, useEffect, useRef } from 'react'; import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar, PresenceBadge, Textarea } from '@fluentui/react-components'; import { Avatar, Button, Menu, MenuPopover, MenuTrigger, PresenceBadge, Textarea } from '@fluentui/react-components';
import commonStore, { ModelStatus } from '../stores/commonStore'; import commonStore, { ModelStatus } from '../stores/commonStore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import classnames from 'classnames'; import classnames from 'classnames';
import { fetchEventSource } from '@microsoft/fetch-event-source'; import { fetchEventSource } from '@microsoft/fetch-event-source';
import { ConversationPair, getConversationPairs, Record } from '../utils/get-conversation-pairs'; import { KebabHorizontalIcon, PencilIcon, SyncIcon, TrashIcon } from '@primer/octicons-react';
import { ConversationPair } from '../utils/get-conversation-pairs';
import logo from '../assets/images/logo.jpg'; import logo from '../assets/images/logo.jpg';
import MarkdownRender from '../components/MarkdownRender'; import MarkdownRender from '../components/MarkdownRender';
import { ToolTipButton } from '../components/ToolTipButton'; import { ToolTipButton } from '../components/ToolTipButton';
@ -22,6 +23,8 @@ import { toastWithButton } from '../utils';
export const userName = 'M E'; export const userName = 'M E';
export const botName = 'A I'; export const botName = 'A I';
export const welcomeUuid = 'welcome';
export enum MessageType { export enum MessageType {
Normal, Normal,
Error Error
@ -48,6 +51,122 @@ export type Conversation = {
let chatSseController: AbortController | null = null; 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 }}>
<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}
image={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);
}} />
}
<CopyButton content={messageItem.content} />
<MoreUtilsButton uuid={uuid} setEditing={setEditingInner} />
</div>
</div>
</div>;
});
const ChatPanel: FC = observer(() => { const ChatPanel: FC = observer(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
@ -71,9 +190,9 @@ const ChatPanel: FC = observer(() => {
useEffect(() => { useEffect(() => {
if (commonStore.conversationOrder.length === 0) { if (commonStore.conversationOrder.length === 0) {
commonStore.setConversationOrder(['welcome']); commonStore.setConversationOrder([welcomeUuid]);
commonStore.setConversation({ commonStore.setConversation({
'welcome': { [welcomeUuid]: {
sender: botName, sender: botName,
type: MessageType.Normal, type: MessageType.Normal,
color: 'colorful', color: 'colorful',
@ -106,38 +225,48 @@ const ChatPanel: FC = observer(() => {
} }
}; };
const onSubmit = (message: string) => { // if message is not null, create a user message;
const newId = uuid(); // if answerId is not null, override the answer with new response;
commonStore.conversation[newId] = { // if startUuid is null, start generating api body messages from first message;
sender: userName, // if endUuid is null, generate api body messages until last message;
type: MessageType.Normal, const onSubmit = useCallback((message: string | null = null, answerId: string | null = null,
color: 'brand', startUuid: string | null = null, endUuid: string | null = null, includeEndUuid: boolean = false) => {
time: new Date().toISOString(), if (message) {
content: message, const newId = uuid();
side: 'right', commonStore.conversation[newId] = {
done: true sender: userName,
}; type: MessageType.Normal,
commonStore.setConversation(commonStore.conversation); color: 'brand',
commonStore.conversationOrder.push(newId); time: new Date().toISOString(),
commonStore.setConversationOrder(commonStore.conversationOrder); content: message,
side: 'right',
done: true
};
commonStore.setConversation(commonStore.conversation);
commonStore.conversationOrder.push(newId);
commonStore.setConversationOrder(commonStore.conversationOrder);
}
const records: Record[] = []; let startIndex = startUuid ? commonStore.conversationOrder.indexOf(startUuid) : 0;
commonStore.conversationOrder.forEach((uuid, index) => { let endIndex = endUuid ? (commonStore.conversationOrder.indexOf(endUuid) + (includeEndUuid ? 1 : 0)) : commonStore.conversationOrder.length;
let targetRange = commonStore.conversationOrder.slice(startIndex, endIndex);
const messages: ConversationPair[] = [];
targetRange.forEach((uuid, index) => {
if (uuid === welcomeUuid)
return;
const messageItem = commonStore.conversation[uuid]; const messageItem = commonStore.conversation[uuid];
if (messageItem.done && messageItem.type === MessageType.Normal && messageItem.sender === botName) { if (messageItem.done && messageItem.type === MessageType.Normal && messageItem.sender === userName) {
if (index > 0) { messages.push({ role: 'user', content: messageItem.content });
const questionId = commonStore.conversationOrder[index - 1]; } else if (messageItem.done && messageItem.type === MessageType.Normal && messageItem.sender === botName) {
const question = commonStore.conversation[questionId]; messages.push({ role: 'assistant', content: messageItem.content });
if (question.done && question.type === MessageType.Normal && question.sender === userName) {
records.push({ question: question.content, answer: messageItem.content });
}
}
} }
}); });
const messages = getConversationPairs(records, false);
(messages as ConversationPair[]).push({ role: 'user', content: message });
const answerId = uuid(); if (answerId === null) {
answerId = uuid();
commonStore.conversationOrder.push(answerId);
}
commonStore.conversation[answerId] = { commonStore.conversation[answerId] = {
sender: botName, sender: botName,
type: MessageType.Normal, type: MessageType.Normal,
@ -149,7 +278,6 @@ const ChatPanel: FC = observer(() => {
done: false done: false
}; };
commonStore.setConversation(commonStore.conversation); commonStore.setConversation(commonStore.conversation);
commonStore.conversationOrder.push(answerId);
commonStore.setConversationOrder(commonStore.conversationOrder); commonStore.setConversationOrder(commonStore.conversationOrder);
setTimeout(scrollToBottom); setTimeout(scrollToBottom);
let answer = ''; let answer = '';
@ -171,11 +299,10 @@ const ChatPanel: FC = observer(() => {
}), }),
signal: chatSseController?.signal, signal: chatSseController?.signal,
onmessage(e) { onmessage(e) {
console.log('sse message', e);
scrollToBottom(); scrollToBottom();
if (e.data === '[DONE]') { if (e.data === '[DONE]') {
commonStore.conversation[answerId].done = true; commonStore.conversation[answerId!].done = true;
commonStore.conversation[answerId].content = commonStore.conversation[answerId].content.trim(); commonStore.conversation[answerId!].content = commonStore.conversation[answerId!].content.trim();
commonStore.setConversation(commonStore.conversation); commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]); commonStore.setConversationOrder([...commonStore.conversationOrder]);
return; return;
@ -189,14 +316,14 @@ const ChatPanel: FC = observer(() => {
} }
if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) { if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) {
answer += data.choices[0]?.delta?.content || ''; answer += data.choices[0]?.delta?.content || '';
commonStore.conversation[answerId].content = answer; commonStore.conversation[answerId!].content = answer;
commonStore.setConversation(commonStore.conversation); commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]); commonStore.setConversationOrder([...commonStore.conversationOrder]);
} }
}, },
async onopen(response) { async onopen(response) {
if (response.status !== 200) { if (response.status !== 200) {
commonStore.conversation[answerId].content += '\n[ERROR]\n```\n' + response.statusText + '\n' + (await response.text()) + '\n```'; commonStore.conversation[answerId!].content += '\n[ERROR]\n```\n' + response.statusText + '\n' + (await response.text()) + '\n```';
commonStore.setConversation(commonStore.conversation); commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]); commonStore.setConversationOrder([...commonStore.conversationOrder]);
setTimeout(scrollToBottom); setTimeout(scrollToBottom);
@ -206,67 +333,25 @@ const ChatPanel: FC = observer(() => {
console.log('Connection closed'); console.log('Connection closed');
}, },
onerror(err) { onerror(err) {
commonStore.conversation[answerId].type = MessageType.Error; commonStore.conversation[answerId!].type = MessageType.Error;
commonStore.conversation[answerId].done = true; commonStore.conversation[answerId!].done = true;
err = err.message || err; err = err.message || err;
if (err && !err.includes('ReadableStreamDefaultReader')) if (err && !err.includes('ReadableStreamDefaultReader'))
commonStore.conversation[answerId].content += '\n[ERROR]\n```\n' + err + '\n```'; commonStore.conversation[answerId!].content += '\n[ERROR]\n```\n' + err + '\n```';
commonStore.setConversation(commonStore.conversation); commonStore.setConversation(commonStore.conversation);
commonStore.setConversationOrder([...commonStore.conversationOrder]); commonStore.setConversationOrder([...commonStore.conversationOrder]);
setTimeout(scrollToBottom); setTimeout(scrollToBottom);
throw err; throw err;
} }
}); });
}; }, []);
return ( return (
<div className="flex flex-col w-full grow gap-4 pt-4 overflow-hidden"> <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"> <div ref={bodyRef} className="grow overflow-y-scroll overflow-x-hidden pr-2">
{commonStore.conversationOrder.map((uuid, index) => { {commonStore.conversationOrder.map(uuid =>
const messageItem = commonStore.conversation[uuid]; <ChatMessageItem key={uuid} uuid={uuid} onSubmit={onSubmit} />
return <div )}
key={uuid}
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}
image={messageItem.avatarImg ? { src: messageItem.avatarImg } : undefined}
/>
<div
className={classnames(
'p-2 rounded-lg overflow-hidden',
messageItem.side === 'left' ? 'bg-gray-200' : 'bg-blue-500',
messageItem.side === 'left' ? 'text-gray-600' : 'text-white'
)}
>
<MarkdownRender>{messageItem.content}</MarkdownRender>
</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}>
<ReadButton content={messageItem.content} />
<CopyButton content={messageItem.content} />
</div>
</div>
</div>;
})}
</div> </div>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<DialogButton tooltip={t('Clear')} <DialogButton tooltip={t('Clear')}