Spaces:
Running
Running
import { IconButton } from "./button"; | |
import { ErrorBoundary } from "./error"; | |
import styles from "./mask.module.scss"; | |
import DownloadIcon from "../icons/download.svg"; | |
import UploadIcon from "../icons/upload.svg"; | |
import EditIcon from "../icons/edit.svg"; | |
import AddIcon from "../icons/add.svg"; | |
import CloseIcon from "../icons/close.svg"; | |
import DeleteIcon from "../icons/delete.svg"; | |
import EyeIcon from "../icons/eye.svg"; | |
import CopyIcon from "../icons/copy.svg"; | |
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask"; | |
import { Message, ModelConfig, ROLES, useChatStore } from "../store"; | |
import { Input, List, ListItem, Modal, Popover, showToast } from "./ui-lib"; | |
import { Avatar, AvatarPicker } from "./emoji"; | |
import Locale, { AllLangs, Lang } from "../locales"; | |
import { useNavigate } from "react-router-dom"; | |
import chatStyle from "./chat.module.scss"; | |
import { useEffect, useState } from "react"; | |
import { downloadAs, readFromFile } from "../utils"; | |
import { Updater } from "../api/openai/typing"; | |
import { ModelConfigList } from "./model-config"; | |
import { FileName, Path } from "../constant"; | |
import { BUILTIN_MASK_STORE } from "../masks"; | |
export function MaskAvatar(props: { mask: Mask }) { | |
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? ( | |
<Avatar avatar={props.mask.avatar} /> | |
) : ( | |
<Avatar model={props.mask.modelConfig.model} /> | |
); | |
} | |
export function MaskConfig(props: { | |
mask: Mask; | |
updateMask: Updater<Mask>; | |
extraListItems?: JSX.Element; | |
readonly?: boolean; | |
}) { | |
const [showPicker, setShowPicker] = useState(false); | |
const updateConfig = (updater: (config: ModelConfig) => void) => { | |
if (props.readonly) return; | |
const config = { ...props.mask.modelConfig }; | |
updater(config); | |
props.updateMask((mask) => (mask.modelConfig = config)); | |
}; | |
return ( | |
<> | |
<ContextPrompts | |
context={props.mask.context} | |
updateContext={(updater) => { | |
const context = props.mask.context.slice(); | |
updater(context); | |
props.updateMask((mask) => (mask.context = context)); | |
}} | |
/> | |
<List> | |
<ListItem title={Locale.Mask.Config.Avatar}> | |
<Popover | |
content={ | |
<AvatarPicker | |
onEmojiClick={(emoji) => { | |
props.updateMask((mask) => (mask.avatar = emoji)); | |
setShowPicker(false); | |
}} | |
></AvatarPicker> | |
} | |
open={showPicker} | |
onClose={() => setShowPicker(false)} | |
> | |
<div | |
onClick={() => setShowPicker(true)} | |
style={{ cursor: "pointer" }} | |
> | |
<MaskAvatar mask={props.mask} /> | |
</div> | |
</Popover> | |
</ListItem> | |
<ListItem title={Locale.Mask.Config.Name}> | |
<input | |
type="text" | |
value={props.mask.name} | |
onInput={(e) => | |
props.updateMask((mask) => (mask.name = e.currentTarget.value)) | |
} | |
></input> | |
</ListItem> | |
</List> | |
<List> | |
<ModelConfigList | |
modelConfig={{ ...props.mask.modelConfig }} | |
updateConfig={updateConfig} | |
/> | |
{props.extraListItems} | |
</List> | |
</> | |
); | |
} | |
export function ContextPrompts(props: { | |
context: Message[]; | |
updateContext: (updater: (context: Message[]) => void) => void; | |
}) { | |
const context = props.context; | |
const addContextPrompt = (prompt: Message) => { | |
props.updateContext((context) => context.push(prompt)); | |
}; | |
const removeContextPrompt = (i: number) => { | |
props.updateContext((context) => context.splice(i, 1)); | |
}; | |
const updateContextPrompt = (i: number, prompt: Message) => { | |
props.updateContext((context) => (context[i] = prompt)); | |
}; | |
return ( | |
<> | |
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}> | |
{context.map((c, i) => ( | |
<div className={chatStyle["context-prompt-row"]} key={i}> | |
<select | |
value={c.role} | |
className={chatStyle["context-role"]} | |
onChange={(e) => | |
updateContextPrompt(i, { | |
...c, | |
role: e.target.value as any, | |
}) | |
} | |
> | |
{ROLES.map((r) => ( | |
<option key={r} value={r}> | |
{r} | |
</option> | |
))} | |
</select> | |
<Input | |
value={c.content} | |
type="text" | |
className={chatStyle["context-content"]} | |
rows={1} | |
onInput={(e) => | |
updateContextPrompt(i, { | |
...c, | |
content: e.currentTarget.value as any, | |
}) | |
} | |
/> | |
<IconButton | |
icon={<DeleteIcon />} | |
className={chatStyle["context-delete-button"]} | |
onClick={() => removeContextPrompt(i)} | |
bordered | |
/> | |
</div> | |
))} | |
<div className={chatStyle["context-prompt-row"]}> | |
<IconButton | |
icon={<AddIcon />} | |
text={Locale.Context.Add} | |
bordered | |
className={chatStyle["context-prompt-button"]} | |
onClick={() => | |
addContextPrompt({ | |
role: "system", | |
content: "", | |
date: "", | |
}) | |
} | |
/> | |
</div> | |
</div> | |
</> | |
); | |
} | |
export function MaskPage() { | |
const navigate = useNavigate(); | |
const maskStore = useMaskStore(); | |
const chatStore = useChatStore(); | |
const [filterLang, setFilterLang] = useState<Lang>(); | |
const allMasks = maskStore | |
.getAll() | |
.filter((m) => !filterLang || m.lang === filterLang); | |
const [searchMasks, setSearchMasks] = useState<Mask[]>([]); | |
const [searchText, setSearchText] = useState(""); | |
const masks = searchText.length > 0 ? searchMasks : allMasks; | |
// simple search, will refactor later | |
const onSearch = (text: string) => { | |
setSearchText(text); | |
if (text.length > 0) { | |
const result = allMasks.filter((m) => m.name.includes(text)); | |
setSearchMasks(result); | |
} else { | |
setSearchMasks(allMasks); | |
} | |
}; | |
const [editingMaskId, setEditingMaskId] = useState<number | undefined>(); | |
const editingMask = | |
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId); | |
const closeMaskModal = () => setEditingMaskId(undefined); | |
const downloadAll = () => { | |
downloadAs(JSON.stringify(masks), FileName.Masks); | |
}; | |
const importFromFile = () => { | |
readFromFile().then((content) => { | |
try { | |
const importMasks = JSON.parse(content); | |
if (Array.isArray(importMasks)) { | |
for (const mask of importMasks) { | |
if (mask.name) { | |
maskStore.create(mask); | |
} | |
} | |
} | |
} catch {} | |
}); | |
}; | |
return ( | |
<ErrorBoundary> | |
<div className={styles["mask-page"]}> | |
<div className="window-header"> | |
<div className="window-header-title"> | |
<div className="window-header-main-title"> | |
{Locale.Mask.Page.Title} | |
</div> | |
<div className="window-header-submai-title"> | |
{Locale.Mask.Page.SubTitle(allMasks.length)} | |
</div> | |
</div> | |
<div className="window-actions"> | |
<div className="window-action-button"> | |
<IconButton | |
icon={<DownloadIcon />} | |
bordered | |
onClick={downloadAll} | |
/> | |
</div> | |
<div className="window-action-button"> | |
<IconButton | |
icon={<UploadIcon />} | |
bordered | |
onClick={() => importFromFile()} | |
/> | |
</div> | |
<div className="window-action-button"> | |
<IconButton | |
icon={<CloseIcon />} | |
bordered | |
onClick={() => navigate(-1)} | |
/> | |
</div> | |
</div> | |
</div> | |
<div className={styles["mask-page-body"]}> | |
<div className={styles["mask-filter"]}> | |
<input | |
type="text" | |
className={styles["search-bar"]} | |
placeholder={Locale.Mask.Page.Search} | |
autoFocus | |
onInput={(e) => onSearch(e.currentTarget.value)} | |
/> | |
<select | |
className={styles["mask-filter-lang"]} | |
value={filterLang ?? Locale.Settings.Lang.All} | |
onChange={(e) => { | |
const value = e.currentTarget.value; | |
if (value === Locale.Settings.Lang.All) { | |
setFilterLang(undefined); | |
} else { | |
setFilterLang(value as Lang); | |
} | |
}} | |
> | |
<option key="all" value={Locale.Settings.Lang.All}> | |
{Locale.Settings.Lang.All} | |
</option> | |
{AllLangs.map((lang) => ( | |
<option value={lang} key={lang}> | |
{Locale.Settings.Lang.Options[lang]} | |
</option> | |
))} | |
</select> | |
<IconButton | |
className={styles["mask-create"]} | |
icon={<AddIcon />} | |
text={Locale.Mask.Page.Create} | |
bordered | |
onClick={() => { | |
const createdMask = maskStore.create(); | |
setEditingMaskId(createdMask.id); | |
}} | |
/> | |
</div> | |
<div> | |
{masks.map((m) => ( | |
<div className={styles["mask-item"]} key={m.id}> | |
<div className={styles["mask-header"]}> | |
<div className={styles["mask-icon"]}> | |
<MaskAvatar mask={m} /> | |
</div> | |
<div className={styles["mask-title"]}> | |
<div className={styles["mask-name"]}>{m.name}</div> | |
<div className={styles["mask-info"] + " one-line"}> | |
{`${Locale.Mask.Item.Info(m.context.length)} / ${ | |
Locale.Settings.Lang.Options[m.lang] | |
} / ${m.modelConfig.model}`} | |
</div> | |
</div> | |
</div> | |
<div className={styles["mask-actions"]}> | |
<IconButton | |
icon={<AddIcon />} | |
text={Locale.Mask.Item.Chat} | |
onClick={() => { | |
chatStore.newSession(m); | |
navigate(Path.Chat); | |
}} | |
/> | |
{m.builtin ? ( | |
<IconButton | |
icon={<EyeIcon />} | |
text={Locale.Mask.Item.View} | |
onClick={() => setEditingMaskId(m.id)} | |
/> | |
) : ( | |
<IconButton | |
icon={<EditIcon />} | |
text={Locale.Mask.Item.Edit} | |
onClick={() => setEditingMaskId(m.id)} | |
/> | |
)} | |
{!m.builtin && ( | |
<IconButton | |
icon={<DeleteIcon />} | |
text={Locale.Mask.Item.Delete} | |
onClick={() => { | |
if (confirm(Locale.Mask.Item.DeleteConfirm)) { | |
maskStore.delete(m.id); | |
} | |
}} | |
/> | |
)} | |
</div> | |
</div> | |
))} | |
</div> | |
</div> | |
</div> | |
{editingMask && ( | |
<div className="modal-mask"> | |
<Modal | |
title={Locale.Mask.EditModal.Title(editingMask?.builtin)} | |
onClose={closeMaskModal} | |
actions={[ | |
<IconButton | |
icon={<DownloadIcon />} | |
text={Locale.Mask.EditModal.Download} | |
key="export" | |
bordered | |
onClick={() => | |
downloadAs( | |
JSON.stringify(editingMask), | |
`${editingMask.name}.json`, | |
) | |
} | |
/>, | |
<IconButton | |
key="copy" | |
icon={<CopyIcon />} | |
bordered | |
text={Locale.Mask.EditModal.Clone} | |
onClick={() => { | |
navigate(Path.Masks); | |
maskStore.create(editingMask); | |
setEditingMaskId(undefined); | |
}} | |
/>, | |
]} | |
> | |
<MaskConfig | |
mask={editingMask} | |
updateMask={(updater) => | |
maskStore.update(editingMaskId!, updater) | |
} | |
readonly={editingMask.builtin} | |
/> | |
</Modal> | |
</div> | |
)} | |
</ErrorBoundary> | |
); | |
} | |