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 DragIcon from "../icons/drag.svg"; import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask"; import { ChatMessage, createMessage, ModelConfig, useAppConfig, useChatStore, } from "../store"; import { ROLES } from "../client/api"; import { Input, List, ListItem, Modal, Popover, Select, showConfirm, } 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 { copyToClipboard, downloadAs, readFromFile } from "../utils"; import { Updater } from "../typing"; import { ModelConfigList } from "./model-config"; import { FileName, Path } from "../constant"; import { BUILTIN_MASK_STORE } from "../masks"; import { nanoid } from "nanoid"; import { DragDropContext, Droppable, Draggable, OnDragEndResponder, } from "@hello-pangea/dnd"; // drag and drop helper function function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] { const result = [...list]; const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); return result; } 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 copyMaskLink = () => { const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`; copyToClipboard(maskLink); }; 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.Share.Title} subTitle={Locale.Mask.Config.Share.SubTitle} > <IconButton icon={<CopyIcon />} text={Locale.Mask.Config.Share.Action} onClick={copyMaskLink} /> </ListItem> ) : null} {props.shouldSyncFromGlobal ? ( <ListItem title={Locale.Mask.Config.Sync.Title} subTitle={Locale.Mask.Config.Sync.SubTitle} > <input type="checkbox" checked={props.mask.syncGlobalConfig} onChange={async (e) => { const checked = e.currentTarget.checked; if ( checked && (await showConfirm(Locale.Mask.Config.Sync.Confirm)) ) { props.updateMask((mask) => { mask.syncGlobalConfig = checked; mask.modelConfig = { ...globalConfig.modelConfig }; }); } else if (!checked) { props.updateMask((mask) => { mask.syncGlobalConfig = checked; }); } }} ></input> </ListItem> ) : null} </List> <List> <ModelConfigList modelConfig={{ ...props.mask.modelConfig }} updateConfig={updateConfig} /> {props.extraListItems} </List> </> ); } function ContextPromptItem(props: { index: number; prompt: ChatMessage; update: (prompt: ChatMessage) => void; remove: () => void; }) { const [focusingInput, setFocusingInput] = useState(false); return ( <div className={chatStyle["context-prompt-row"]}> {!focusingInput && ( <> <div className={chatStyle["context-drag"]}> <DragIcon /> </div> <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); // If the selection is not removed when the user loses focus, some // extensions like "Translate" will always display a floating bar window?.getSelection()?.removeAllRanges(); }} 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, i: number) => { props.updateContext((context) => context.splice(i, 0, prompt)); }; const removeContextPrompt = (i: number) => { props.updateContext((context) => context.splice(i, 1)); }; const updateContextPrompt = (i: number, prompt: ChatMessage) => { props.updateContext((context) => (context[i] = prompt)); }; const onDragEnd: OnDragEndResponder = (result) => { if (!result.destination) { return; } const newContext = reorder( context, result.source.index, result.destination.index, ); props.updateContext((context) => { context.splice(0, context.length, ...newContext); }); }; return ( <> <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}> <DragDropContext onDragEnd={onDragEnd}> <Droppable droppableId="context-prompt-list"> {(provided) => ( <div ref={provided.innerRef} {...provided.droppableProps}> {context.map((c, i) => ( <Draggable draggableId={c.id || i.toString()} index={i} key={c.id} > {(provided) => ( <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} > <ContextPromptItem index={i} prompt={c} update={(prompt) => updateContextPrompt(i, prompt)} remove={() => removeContextPrompt(i)} /> <div className={chatStyle["context-prompt-insert"]} onClick={() => { addContextPrompt( createMessage({ role: "user", content: "", date: new Date().toLocaleString(), }), i + 1, ); }} > <AddIcon /> </div> </div> )} </Draggable> ))} {provided.placeholder} </div> )} </Droppable> </DragDropContext> {props.context.length === 0 && ( <div className={chatStyle["context-prompt-row"]}> <IconButton icon={<AddIcon />} text={Locale.Context.Add} bordered className={chatStyle["context-prompt-button"]} onClick={() => addContextPrompt( createMessage({ role: "user", content: "", date: "", }), props.context.length, ) } /> </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; // refactored already, now it accurate const onSearch = (text: string) => { setSearchText(text); if (text.length > 0) { const result = allMasks.filter((m) => m.name.toLowerCase().includes(text.toLowerCase()) ); setSearchMasks(result); } else { setSearchMasks(allMasks); } }; const [editingMaskId, setEditingMaskId] = useState<string | undefined>(); const editingMask = maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId); const closeMaskModal = () => setEditingMaskId(undefined); const downloadAll = () => { downloadAs(JSON.stringify(masks.filter((v) => !v.builtin)), 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} text={Locale.UI.Export} /> </div> <div className="window-action-button"> <IconButton icon={<UploadIcon />} text={Locale.UI.Import} 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={async () => { if (await showConfirm(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.updateMask(editingMaskId!, updater) } readonly={editingMask.builtin} /> </Modal> </div> )} </ErrorBoundary> ); }