import type { OnDragEndResponder, } from '@hello-pangea/dnd'; import type { MultimodalContent } from '../client/api'; import type { Lang } from '../locales'; import type { ChatMessage, ModelConfig, ModelType, } from '../store'; import type { Mask } from '../store/mask'; import type { Updater } from '../typing'; import { DragDropContext, Draggable, Droppable, } from '@hello-pangea/dnd'; import clsx from 'clsx'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ROLES } from '../client/api'; import { FileName, Path } from '../constant'; import AddIcon from '../icons/add.svg'; import CloseIcon from '../icons/close.svg'; import CopyIcon from '../icons/copy.svg'; import DeleteIcon from '../icons/delete.svg'; import DownloadIcon from '../icons/download.svg'; import DragIcon from '../icons/drag.svg'; import EditIcon from '../icons/edit.svg'; import EyeIcon from '../icons/eye.svg'; import UploadIcon from '../icons/upload.svg'; import Locale, { ALL_LANG_OPTIONS, AllLangs } from '../locales'; import { BUILTIN_MASK_STORE } from '../masks'; import { createMessage, useAppConfig, useChatStore, } from '../store'; import { DEFAULT_MASK_AVATAR, useMaskStore } from '../store/mask'; import { copyToClipboard, downloadAs, getMessageImages, getMessageTextContent, readFromFile, } from '../utils'; import { IconButton } from './button'; import chatStyle from './chat.module.scss'; import { Avatar, AvatarPicker } from './emoji'; import { ErrorBoundary } from './error'; import styles from './mask.module.scss'; import { ModelConfigList } from './model-config'; import { Input, List, ListItem, Modal, Popover, Select, showConfirm, } from './ui-lib'; // 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; }); }} > {globalConfig.enableArtifacts && ( { props.updateMask((mask) => { mask.enableArtifacts = e.currentTarget.checked; }); }} > )} {globalConfig.enableCodeFold && ( { props.updateMask((mask) => { mask.enableCodeFold = 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 = maskStore.language; 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} />
)}
); }