2023-06-28 20:49:41 +08:00

431 lines
13 KiB
TypeScript

// TODO refactor
import React, { FC, PropsWithChildren, ReactElement, useState } from 'react';
import {
Button,
Dialog,
DialogBody,
DialogContent,
DialogSurface,
DialogTrigger,
Input,
Switch,
Tab,
TabList,
Text
} from '@fluentui/react-components';
import {
Accessibility28Regular,
Chat20Regular,
ClipboardEdit20Regular,
Delete20Regular,
Dismiss20Regular,
Edit20Regular,
Globe20Regular
} from '@fluentui/react-icons';
import { ToolTipButton } from '../../components/ToolTipButton';
import { useTranslation } from 'react-i18next';
import { botName, Conversation, ConversationMessage, MessageType, userName } from '../Chat';
import { SelectTabEventHandler } from '@fluentui/react-tabs';
import { Labeled } from '../../components/Labeled';
import commonStore from '../../stores/commonStore';
import logo from '../../assets/images/logo.jpg';
import { observer } from 'mobx-react-lite';
import { MessagesEditor } from './MessagesEditor';
import { ClipboardGetText, ClipboardSetText } from '../../../wailsjs/runtime';
import { toast } from 'react-toastify';
import { CustomToastContainer } from '../../components/CustomToastContainer';
import { v4 as uuid } from 'uuid';
export type PresetType = 'chat' | 'completion' | 'chatInCompletion'
export type Preset = {
name: string,
tag: string,
// if name and sourceUrl are same, it will be overridden when importing
sourceUrl: string,
desc: string,
avatarImg: string,
type: PresetType,
// chat
welcomeMessage: string,
messages: ConversationMessage[],
displayPresetMessages: boolean,
// completion
prompt: string,
stop: string,
injectStart: string,
injectEnd: string,
}
export const defaultPreset: Preset = {
name: 'RWKV',
tag: 'default',
sourceUrl: '',
desc: '',
avatarImg: logo,
type: 'chat',
welcomeMessage: '',
displayPresetMessages: true,
messages: [],
prompt: '',
stop: '',
injectStart: '',
injectEnd: ''
};
const setActivePreset = (preset: Preset) => {
commonStore.setActivePreset(preset);
//TODO if (preset.displayPresetMessages) {
const conversation: Conversation = {};
const conversationOrder: string[] = [];
for (const message of preset.messages) {
const newUuid = uuid();
conversationOrder.push(newUuid);
conversation[newUuid] = {
sender: message.role === 'user' ? userName : botName,
type: MessageType.Normal,
color: message.role === 'user' ? 'brand' : 'colorful',
time: new Date().toISOString(),
content: message.content,
side: message.role === 'user' ? 'right' : 'left',
done: true
};
}
commonStore.setConversation(conversation);
commonStore.setConversationOrder(conversationOrder);
//}
};
export const PresetCardFrame: FC<PropsWithChildren & { onClick?: () => void }> = (props) => {
return <Button
className="flex flex-col gap-1 w-32 h-56 break-all"
style={{ minWidth: 0, borderRadius: '0.75rem', justifyContent: 'unset' }}
onClick={props.onClick}
>
{props.children}
</Button>;
};
export const PresetCard: FC<{
avatarImg: string,
name: string,
desc: string,
tag: string,
editable: boolean,
presetIndex: number,
onClick?: () => void
}> = observer(({
avatarImg, name, desc, tag, editable, presetIndex, onClick
}) => {
const { t } = useTranslation();
return <PresetCardFrame onClick={onClick}>
<img src={avatarImg} className="rounded-xl select-none ml-auto mr-auto h-28" />
<Text size={400}>{name}</Text>
<Text size={200} style={{
overflow: 'hidden', textOverflow: 'ellipsis',
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical'
}}>{desc}</Text>
<div className="grow" />
<div className="flex justify-between w-full items-end">
<div className="text-xs font-thin text-gray-500">{t(tag)}</div>
{editable ?
<ChatPresetEditor presetIndex={presetIndex} triggerButton={
<ToolTipButton size="small" appearance="transparent" desc={t('Edit')} icon={<Edit20Regular />}
onClick={() => {
commonStore.setEditingPreset({ ...commonStore.presets[presetIndex] });
}} />
} />
: <div />
}
</div>
</PresetCardFrame>;
});
export const ChatPresetEditor: FC<{
triggerButton: ReactElement,
presetIndex: number
}> = observer(({ triggerButton, presetIndex }) => {
const { t } = useTranslation();
const [editingMessages, setEditingMessages] = useState(false);
if (!commonStore.editingPreset)
commonStore.setEditingPreset({ ...defaultPreset });
const editingPreset = commonStore.editingPreset!;
const setEditingPreset = (newParams: Partial<Preset>) => {
commonStore.setEditingPreset({
...editingPreset,
...newParams
});
};
const importPreset = () => {
ClipboardGetText().then((text) => {
try {
const preset = JSON.parse(text);
setEditingPreset(preset);
toast(t('Imported successfully'), {
type: 'success',
autoClose: 1000
});
} catch (e) {
toast(t('Failed to import. Please copy a preset to the clipboard.'), {
type: 'error',
autoClose: 2500
});
}
}).catch(() => {
toast(t('Clipboard is empty.'), {
type: 'info',
autoClose: 1000
});
});
};
const copyPreset = () => {
ClipboardSetText(JSON.stringify(editingPreset)).then((success) => {
if (success)
toast(t('Successfully copied to clipboard.'), {
type: 'success',
autoClose: 1000
});
});
};
const savePreset = () => {
if (presetIndex === -1) {
commonStore.setPresets([...commonStore.presets, { ...editingPreset }]);
setEditingPreset(defaultPreset);
} else {
commonStore.presets[presetIndex] = editingPreset;
commonStore.setPresets(commonStore.presets);
}
};
const activatePreset = () => {
savePreset();
setActivePreset(editingPreset);
};
const deletePreset = () => {
commonStore.presets.splice(presetIndex, 1);
commonStore.setPresets(commonStore.presets);
};
return <Dialog>
<DialogTrigger disableButtonEnhancement>
{triggerButton}
</DialogTrigger>
<DialogSurface style={{
paddingTop: 0,
maxWidth: '80vw',
maxHeight: '80vh',
width: '500px',
height: '100%'
}}>
<DialogBody style={{ height: '100%', overflow: 'hidden' }}>
<DialogContent className="flex flex-col gap-1 overflow-hidden">
<CustomToastContainer />
<div className="flex justify-between">{
presetIndex === -1
? <div />
: <DialogTrigger disableButtonEnhancement>
<Button appearance="subtle" icon={<Delete20Regular />} onClick={deletePreset} />
</DialogTrigger>
}
<DialogTrigger disableButtonEnhancement>
<Button appearance="subtle" icon={<Dismiss20Regular />} />
</DialogTrigger>
</div>
<img src={editingPreset.avatarImg} className="rounded-xl select-none ml-auto mr-auto h-28" />
<Labeled flex breakline label={t('Name')}
content={
<div className="flex gap-2">
<Input className="grow" value={editingPreset.name} onChange={(e, data) => {
setEditingPreset({
name: data.value
});
}} />
<Button onClick={() => {
setEditingMessages(!editingMessages);
}}>{!editingMessages ? t('Edit Messages') : t('Go Back')}</Button>
</div>
} />
{
editingMessages ?
<MessagesEditor /> :
<div className="flex flex-col gap-1 p-2 overflow-x-hidden overflow-y-auto">
<Labeled flex breakline label={t('Description')}
content={
<Input value={editingPreset.desc} onChange={(e, data) => {
setEditingPreset({
desc: data.value
});
}} />
} />
<Labeled flex breakline label={t('Avatar Url')}
content={
<Input value={editingPreset.avatarImg} onChange={(e, data) => {
setEditingPreset({
avatarImg: data.value
});
}} />
} />
<Labeled flex breakline label={t('Welcome Message')}
content={
<Input disabled value={editingPreset.welcomeMessage} onChange={(e, data) => {
setEditingPreset({
welcomeMessage: data.value
});
}} />
} />
<Labeled flex spaceBetween label={t('Display Preset Messages')}
content={
<Switch disabled checked={editingPreset.displayPresetMessages}
onChange={(e, data) => {
setEditingPreset({
displayPresetMessages: data.checked
});
}} />
} />
<Labeled flex breakline label={t('Tag')}
content={
<Input value={editingPreset.tag} onChange={(e, data) => {
setEditingPreset({
tag: data.value
});
}} />
} />
</div>
}
<div className="grow" />
<div className="flex justify-between">
<Button onClick={importPreset}>{t('Import')}</Button>
<Button onClick={copyPreset}>{t('Copy')}</Button>
</div>
<div className="flex justify-between">
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" onClick={savePreset}>{t('Save')}</Button>
</DialogTrigger>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" onClick={activatePreset}>{t('Activate')}</Button>
</DialogTrigger>
</div>
</DialogContent>
</DialogBody>
</DialogSurface>
</Dialog>;
});
export const ChatPresets: FC = observer(() => {
const { t } = useTranslation();
return <div className="flex flex-wrap gap-2">
<ChatPresetEditor presetIndex={-1} triggerButton={
<PresetCardFrame>
<div className="h-full flex items-center">
{t('New Preset')}
</div>
</PresetCardFrame>}
/>
{/*TODO <PresetCardFrame>*/}
{/* <div className="h-full flex items-center">*/}
{/* {t('Import')}*/}
{/* </div>*/}
{/*</PresetCardFrame>*/}
<PresetCard
presetIndex={-1}
editable={false}
onClick={() => {
setActivePreset(defaultPreset);
}} avatarImg={defaultPreset.avatarImg} name={defaultPreset.name} desc={defaultPreset.desc} tag={defaultPreset.tag}
/>
{commonStore.presets.map((preset, index) => {
return <PresetCard
presetIndex={index}
editable={true}
onClick={() => {
setActivePreset(preset);
}}
key={index} avatarImg={preset.avatarImg} name={preset.name} desc={preset.desc} tag={preset.tag}
/>;
})}
</div>;
});
type PresetsNavigationItem = {
icon: ReactElement;
element: ReactElement;
};
const pages: { [label: string]: PresetsNavigationItem } = {
Chat: {
icon: <Chat20Regular />,
element: <ChatPresets />
},
Completion: {
icon: <ClipboardEdit20Regular />,
element: <div>In Development</div>
},
Online: {
icon: <Globe20Regular />,
element: <div>In Development</div>
}
};
export const PresetsManager: FC<{ initTab: string }> = ({ initTab }) => {
const { t } = useTranslation();
const [tab, setTab] = useState(initTab);
const selectTab: SelectTabEventHandler = (e, data) =>
typeof data.value === 'string' ? setTab(data.value) : null;
return <div className="flex flex-col gap-2 w-full h-full">
<div className="flex justify-between">
<TabList
size="small"
appearance="subtle"
selectedValue={tab}
onTabSelect={selectTab}
>
{Object.entries(pages).map(([label, { icon }]) => (
<Tab icon={icon} key={label} value={label}>
{t(label)}
</Tab>
))}
</TabList>
<DialogTrigger disableButtonEnhancement>
<Button appearance="subtle" icon={<Dismiss20Regular />} />
</DialogTrigger>
</div>
<div className="grow overflow-x-hidden overflow-y-auto">
{pages[tab].element}
</div>
</div>;
};
export const PresetsButton: FC<{
tab: string,
size?: 'small' | 'medium' | 'large',
shape?: 'rounded' | 'circular' | 'square';
appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent';
}> = ({ tab, size, shape, appearance }) => {
const { t } = useTranslation();
return <Dialog>
<DialogTrigger disableButtonEnhancement>
<ToolTipButton desc={t('Presets')} size={size} shape={shape} appearance={appearance}
icon={<Accessibility28Regular />} />
</DialogTrigger>
<DialogSurface style={{ paddingTop: 0, maxWidth: '90vw', width: 'fit-content' }}>
<DialogBody>
<DialogContent>
<CustomToastContainer />
<PresetsManager initTab={tab} />
</DialogContent>
</DialogBody>
</DialogSurface>
</Dialog>;
};