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 { ChatMessage, ModelConfig, useAppConfig, useChatStore } from "../store"; import { ROLES } from "../client/api"; import { Input, List, ListItem, Modal, Popover, Select } from "./ui-lib"; import { Avatar, AvatarPicker } from "./emoji"; import Locale, { AllLangs, ALL_LANG_OPTIONS, 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 "../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; shouldSyncFromGlobal?: 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; // if user changed current session mask, it will disable auto sync mask.syncGlobalConfig = false; }); }; const globalConfig = useAppConfig(); 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> <ListItem title={Locale.Mask.Config.HideContext.Title} subTitle={Locale.Mask.Config.HideContext.SubTitle} > <input type="checkbox" checked={props.mask.hideContext} onChange={(e) => { props.updateMask((mask) => { mask.hideContext = e.currentTarget.checked; }); }} ></input> </ListItem> {props.shouldSyncFromGlobal ? ( <ListItem title={Locale.Mask.Config.Sync.Title} subTitle={Locale.Mask.Config.Sync.SubTitle} > <input type="checkbox" checked={props.mask.syncGlobalConfig} onChange={(e) => { if ( e.currentTarget.checked && confirm(Locale.Mask.Config.Sync.Confirm) ) { props.updateMask((mask) => { mask.syncGlobalConfig = e.currentTarget.checked; mask.modelConfig = { ...globalConfig.modelConfig }; }); } }} ></input> </ListItem> ) : null} </List> <List> <ModelConfigList modelConfig={{ ...props.mask.modelConfig }} updateConfig={updateConfig} /> {props.extraListItems} </List> </> ); } function ContextPromptItem(props: { prompt: ChatMessage; update: (prompt: ChatMessage) => void; remove: () => void; }) { const [focusingInput, setFocusingInput] = useState(false); return ( <div className={chatStyle["context-prompt-row"]}> {!focusingInput && ( <Select value={props.prompt.role} className={chatStyle["context-role"]} onChange={(e) => props.update({ ...props.prompt, role: e.target.value as any, }) } > {ROLES.map((r) => ( <option key={r} value={r}> {r} </option> ))} </Select> )} <Input value={props.prompt.content} type="text" className={chatStyle["context-content"]} rows={focusingInput ? 5 : 1} onFocus={() => setFocusingInput(true)} onBlur={() => setFocusingInput(false)} onInput={(e) => props.update({ ...props.prompt, content: e.currentTarget.value as any, }) } /> {!focusingInput && ( <IconButton icon={<DeleteIcon />} className={chatStyle["context-delete-button"]} onClick={() => props.remove()} bordered /> )} </div> ); } export function ContextPrompts(props: { context: ChatMessage[]; updateContext: (updater: (context: ChatMessage[]) => void) => void; }) { const context = props.context; const addContextPrompt = (prompt: ChatMessage) => { props.updateContext((context) => context.push(prompt)); }; const removeContextPrompt = (i: number) => { props.updateContext((context) => context.splice(i, 1)); }; const updateContextPrompt = (i: number, prompt: ChatMessage) => { props.updateContext((context) => (context[i] = prompt)); }; return ( <> <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}> {context.map((c, i) => ( <ContextPromptItem key={i} prompt={c} update={(prompt) => updateContextPrompt(i, prompt)} remove={() => removeContextPrompt(i)} /> ))} <div className={chatStyle["context-prompt-row"]}> <IconButton icon={<AddIcon />} text={Locale.Context.Add} bordered className={chatStyle["context-prompt-button"]} onClick={() => addContextPrompt({ role: "user", 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); } } return; } //if the content is a single mask. if (importMasks.name) { maskStore.create(importMasks); } } 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}> {ALL_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)} / ${ ALL_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> ); }