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, ModelType, useAppConfig, useChatStore, } from "../store"; import { MultimodalContent, 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, getMessageImages, 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"; import { getMessageTextContent } from "../utils"; // drag and drop helper function function reorder(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: { avatar: string; model?: ModelType }) { return props.avatar !== DEFAULT_MASK_AVATAR ? ( ) : ( ); } export function MaskConfig(props: { mask: Mask; updateMask: Updater; 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 ( <> { const context = props.mask.context.slice(); updater(context); props.updateMask((mask) => (mask.context = context)); }} /> { props.updateMask((mask) => (mask.avatar = emoji)); setShowPicker(false); }} > } open={showPicker} onClose={() => setShowPicker(false)} >
setShowPicker(true)} style={{ cursor: "pointer" }} >
props.updateMask((mask) => { mask.name = e.currentTarget.value; }) } > { props.updateMask((mask) => { mask.hideContext = e.currentTarget.checked; }); }} > {!props.shouldSyncFromGlobal ? ( } text={Locale.Mask.Config.Share.Action} onClick={copyMaskLink} /> ) : null} {props.shouldSyncFromGlobal ? ( { 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; }); } }} > ) : null}
{props.extraListItems} ); } function ContextPromptItem(props: { index: number; prompt: ChatMessage; update: (prompt: ChatMessage) => void; remove: () => void; }) { const [focusingInput, setFocusingInput] = useState(false); return (
{!focusingInput && ( <>
)} 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 && ( } className={chatStyle["context-delete-button"]} onClick={() => props.remove()} bordered /> )}
); } 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) => { const images = getMessageImages(context[i]); context[i] = prompt; if (images.length > 0) { const text = getMessageTextContent(context[i]); const newContext: MultimodalContent[] = [{ type: "text", text }]; for (const img of images) { newContext.push({ type: "image_url", image_url: { url: img } }); } context[i].content = newContext; } }); }; 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 ( <>
{(provided) => (
{context.map((c, i) => ( {(provided) => (
updateContextPrompt(i, prompt)} remove={() => removeContextPrompt(i)} />
{ addContextPrompt( createMessage({ role: "user", content: "", date: new Date().toLocaleString(), }), i + 1, ); }} >
)}
))} {provided.placeholder}
)}
{props.context.length === 0 && (
} text={Locale.Context.Add} bordered className={chatStyle["context-prompt-button"]} onClick={() => addContextPrompt( createMessage({ role: "user", content: "", date: "", }), props.context.length, ) } />
)}
); } export function MaskPage() { const navigate = useNavigate(); const maskStore = useMaskStore(); const chatStore = useChatStore(); const [filterLang, setFilterLang] = useState( () => localStorage.getItem("Mask-language") as Lang | undefined, ); useEffect(() => { if (filterLang) { localStorage.setItem("Mask-language", filterLang); } else { localStorage.removeItem("Mask-language"); } }, [filterLang]); const allMasks = maskStore .getAll() .filter((m) => !filterLang || m.lang === filterLang); const [searchMasks, setSearchMasks] = useState([]); 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(); 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 (
{Locale.Mask.Page.Title}
{Locale.Mask.Page.SubTitle(allMasks.length)}
} bordered onClick={downloadAll} text={Locale.UI.Export} />
} text={Locale.UI.Import} bordered onClick={() => importFromFile()} />
} bordered onClick={() => navigate(-1)} />
onSearch(e.currentTarget.value)} /> } text={Locale.Mask.Page.Create} bordered onClick={() => { const createdMask = maskStore.create(); setEditingMaskId(createdMask.id); }} />
{masks.map((m) => (
{m.name}
{`${Locale.Mask.Item.Info(m.context.length)} / ${ ALL_LANG_OPTIONS[m.lang] } / ${m.modelConfig.model}`}
} text={Locale.Mask.Item.Chat} onClick={() => { chatStore.newSession(m); navigate(Path.Chat); }} /> {m.builtin ? ( } text={Locale.Mask.Item.View} onClick={() => setEditingMaskId(m.id)} /> ) : ( } text={Locale.Mask.Item.Edit} onClick={() => setEditingMaskId(m.id)} /> )} {!m.builtin && ( } text={Locale.Mask.Item.Delete} onClick={async () => { if (await showConfirm(Locale.Mask.Item.DeleteConfirm)) { maskStore.delete(m.id); } }} /> )}
))}
{editingMask && (
} text={Locale.Mask.EditModal.Download} key="export" bordered onClick={() => downloadAs( JSON.stringify(editingMask), `${editingMask.name}.json`, ) } />, } bordered text={Locale.Mask.EditModal.Clone} onClick={() => { navigate(Path.Masks); maskStore.create(editingMask); setEditingMaskId(undefined); }} />, ]} > maskStore.updateMask(editingMaskId!, updater) } readonly={editingMask.builtin} />
)}
); }