@ -0,0 +1,59 @@
|
|||||||
|
import EmojiPicker, {
|
||||||
|
Emoji,
|
||||||
|
EmojiStyle,
|
||||||
|
Theme as EmojiTheme,
|
||||||
|
} from "emoji-picker-react";
|
||||||
|
|
||||||
|
import { ModelType } from "../store";
|
||||||
|
|
||||||
|
import BotIcon from "../icons/bot.svg";
|
||||||
|
import BlackBotIcon from "../icons/black-bot.svg";
|
||||||
|
|
||||||
|
export function getEmojiUrl(unified: string, style: EmojiStyle) {
|
||||||
|
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarPicker(props: {
|
||||||
|
onEmojiClick: (emojiId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EmojiPicker
|
||||||
|
lazyLoadEmojis
|
||||||
|
theme={EmojiTheme.AUTO}
|
||||||
|
getEmojiUrl={getEmojiUrl}
|
||||||
|
onEmojiClick={(e) => {
|
||||||
|
props.onEmojiClick(e.unified);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||||
|
if (props.model) {
|
||||||
|
return (
|
||||||
|
<div className="no-dark">
|
||||||
|
{props.model?.startsWith("gpt-4") ? (
|
||||||
|
<BlackBotIcon className="user-avatar" />
|
||||||
|
) : (
|
||||||
|
<BotIcon className="user-avatar" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-avatar">
|
||||||
|
{props.avatar && <EmojiAvatar avatar={props.avatar} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiAvatar(props: { avatar: string; size?: number }) {
|
||||||
|
return (
|
||||||
|
<Emoji
|
||||||
|
unified={props.avatar}
|
||||||
|
size={props.size ?? 18}
|
||||||
|
getEmojiUrl={getEmojiUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,124 @@
|
|||||||
|
@import "../styles/animation.scss";
|
||||||
|
|
||||||
|
@keyframes search-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5vh) scaleX(0.5);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.mask-page-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.mask-filter {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
animation: search-in ease 0.3s;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: search-in ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-filter-lang {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-create {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mask-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-title {
|
||||||
|
.mask-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.mask-info {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: var(--border-in-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,397 @@
|
|||||||
|
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 } 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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={() => showToast(Locale.WIP)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className={styles["mask-create"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<AddIcon />}
|
||||||
|
text={Locale.Mask.Page.Create}
|
||||||
|
bordered
|
||||||
|
onClick={() => maskStore.create()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
/>,
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,141 @@
|
|||||||
|
import styles from "./settings.module.scss";
|
||||||
|
import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
|
||||||
|
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { InputRange } from "./input-range";
|
||||||
|
import { List, ListItem } from "./ui-lib";
|
||||||
|
|
||||||
|
export function ModelConfigList(props: {
|
||||||
|
modelConfig: ModelConfig;
|
||||||
|
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem title={Locale.Settings.Model}>
|
||||||
|
<select
|
||||||
|
value={props.modelConfig.model}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.model = ModalConfigValidator.model(
|
||||||
|
e.currentTarget.value,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ALL_MODELS.map((v) => (
|
||||||
|
<option value={v.name} key={v.name} disabled={!v.available}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Temperature.Title}
|
||||||
|
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
value={props.modelConfig.temperature?.toFixed(1)}
|
||||||
|
min="0"
|
||||||
|
max="1" // lets limit it to 0-1
|
||||||
|
step="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.temperature = ModalConfigValidator.temperature(
|
||||||
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.MaxTokens.Title}
|
||||||
|
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={32000}
|
||||||
|
value={props.modelConfig.max_tokens}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.max_tokens = ModalConfigValidator.max_tokens(
|
||||||
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.PresencePenlty.Title}
|
||||||
|
subTitle={Locale.Settings.PresencePenlty.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||||
|
min="-2"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.presence_penalty =
|
||||||
|
ModalConfigValidator.presence_penalty(
|
||||||
|
e.currentTarget.valueAsNumber,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.HistoryCount.Title}
|
||||||
|
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||||
|
>
|
||||||
|
<InputRange
|
||||||
|
title={props.modelConfig.historyMessageCount.toString()}
|
||||||
|
value={props.modelConfig.historyMessageCount}
|
||||||
|
min="0"
|
||||||
|
max="32"
|
||||||
|
step="1"
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.historyMessageCount = e.target.valueAsNumber),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></InputRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.CompressThreshold.Title}
|
||||||
|
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={500}
|
||||||
|
max={4000}
|
||||||
|
value={props.modelConfig.compressMessageLengthThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.compressMessageLengthThreshold =
|
||||||
|
e.currentTarget.valueAsNumber),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.modelConfig.sendMemory}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.sendMemory = e.currentTarget.checked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
@import "../styles/animation.scss";
|
||||||
|
|
||||||
|
.new-chat {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.mask-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: slide-in-from-top ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-cards {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 5vh;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
|
.mask-card {
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: var(--white);
|
||||||
|
transform: scale(1);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
transform: rotate(-15deg) translateY(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
transform: rotate(15deg) translateY(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bolder;
|
||||||
|
margin-bottom: 1vh;
|
||||||
|
animation: slide-in ease 0.35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
animation: slide-in ease 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 5vh;
|
||||||
|
margin-bottom: 5vh;
|
||||||
|
animation: slide-in ease 0.45s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 10px;
|
||||||
|
width: 40vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.masks {
|
||||||
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
|
||||||
|
animation: slide-in ease 0.5s;
|
||||||
|
|
||||||
|
.mask-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@for $i from 1 to 10 {
|
||||||
|
&:nth-child(#{$i * 2}) {
|
||||||
|
margin-left: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
background-color: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
max-width: 8em;
|
||||||
|
transform: scale(1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-5px) scale(1.1);
|
||||||
|
z-index: 999;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-name {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Path, SlotID } from "../constant";
|
||||||
|
import { IconButton } from "./button";
|
||||||
|
import { EmojiAvatar } from "./emoji";
|
||||||
|
import styles from "./new-chat.module.scss";
|
||||||
|
|
||||||
|
import LeftIcon from "../icons/left.svg";
|
||||||
|
import AddIcon from "../icons/lightning.svg";
|
||||||
|
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { createEmptyMask, Mask, useMaskStore } from "../store/mask";
|
||||||
|
import Locale from "../locales";
|
||||||
|
import { useAppConfig, useChatStore } from "../store";
|
||||||
|
import { MaskAvatar } from "./mask";
|
||||||
|
|
||||||
|
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
|
||||||
|
const xmin = Math.max(aRect.x, bRect.x);
|
||||||
|
const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
|
||||||
|
const ymin = Math.max(aRect.y, bRect.y);
|
||||||
|
const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
|
||||||
|
const width = xmax - xmin;
|
||||||
|
const height = ymax - ymin;
|
||||||
|
const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
|
||||||
|
return intersectionArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
|
const domRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const changeOpacity = () => {
|
||||||
|
const dom = domRef.current;
|
||||||
|
const parent = document.getElementById(SlotID.AppBody);
|
||||||
|
if (!parent || !dom) return;
|
||||||
|
|
||||||
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
const intersectionArea = getIntersectionArea(domRect, parentRect);
|
||||||
|
const domArea = domRect.width * domRect.height;
|
||||||
|
const ratio = intersectionArea / domArea;
|
||||||
|
const opacity = ratio > 0.9 ? 1 : 0.4;
|
||||||
|
dom.style.opacity = opacity.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(changeOpacity, 30);
|
||||||
|
|
||||||
|
window.addEventListener("resize", changeOpacity);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", changeOpacity);
|
||||||
|
}, [domRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["mask"]} ref={domRef} onClick={props.onClick}>
|
||||||
|
<MaskAvatar mask={props.mask} />
|
||||||
|
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMaskGroup(masks: Mask[]) {
|
||||||
|
const [groups, setGroups] = useState<Mask[][]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const appBody = document.getElementById(SlotID.AppBody);
|
||||||
|
if (!appBody || masks.length === 0) return;
|
||||||
|
|
||||||
|
const rect = appBody.getBoundingClientRect();
|
||||||
|
const maxWidth = rect.width;
|
||||||
|
const maxHeight = rect.height * 0.6;
|
||||||
|
const maskItemWidth = 120;
|
||||||
|
const maskItemHeight = 50;
|
||||||
|
|
||||||
|
const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
|
||||||
|
let maskIndex = 0;
|
||||||
|
const nextMask = () => masks[maskIndex++ % masks.length];
|
||||||
|
|
||||||
|
const rows = Math.ceil(maxHeight / maskItemHeight);
|
||||||
|
const cols = Math.ceil(maxWidth / maskItemWidth);
|
||||||
|
|
||||||
|
const newGroups = new Array(rows)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, _i) =>
|
||||||
|
new Array(cols)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
|
||||||
|
);
|
||||||
|
|
||||||
|
setGroups(newGroups);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewChat() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const maskStore = useMaskStore();
|
||||||
|
|
||||||
|
const masks = maskStore.getAll();
|
||||||
|
const groups = useMaskGroup(masks);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const { state } = useLocation();
|
||||||
|
|
||||||
|
const startChat = (mask?: Mask) => {
|
||||||
|
chatStore.newSession(mask);
|
||||||
|
navigate(Path.Chat);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["new-chat"]}>
|
||||||
|
<div className={styles["mask-header"]}>
|
||||||
|
<IconButton
|
||||||
|
icon={<LeftIcon />}
|
||||||
|
text={Locale.NewChat.Return}
|
||||||
|
onClick={() => navigate(Path.Home)}
|
||||||
|
></IconButton>
|
||||||
|
{!state?.fromHome && (
|
||||||
|
<IconButton
|
||||||
|
text={Locale.NewChat.NotShow}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(Locale.NewChat.ConfirmNoShow)) {
|
||||||
|
startChat();
|
||||||
|
config.update(
|
||||||
|
(config) => (config.dontShowMaskSplashScreen = true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles["mask-cards"]}>
|
||||||
|
<div className={styles["mask-card"]}>
|
||||||
|
<EmojiAvatar avatar="1f606" size={24} />
|
||||||
|
</div>
|
||||||
|
<div className={styles["mask-card"]}>
|
||||||
|
<EmojiAvatar avatar="1f916" size={24} />
|
||||||
|
</div>
|
||||||
|
<div className={styles["mask-card"]}>
|
||||||
|
<EmojiAvatar avatar="1f479" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["title"]}>{Locale.NewChat.Title}</div>
|
||||||
|
<div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
|
||||||
|
|
||||||
|
<div className={styles["actions"]}>
|
||||||
|
<input
|
||||||
|
className={styles["search-bar"]}
|
||||||
|
placeholder={Locale.NewChat.More}
|
||||||
|
type="text"
|
||||||
|
onClick={() => navigate(Path.Masks)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
text={Locale.NewChat.Skip}
|
||||||
|
onClick={() => startChat()}
|
||||||
|
icon={<AddIcon />}
|
||||||
|
type="primary"
|
||||||
|
shadow
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["masks"]}>
|
||||||
|
{groups.map((masks, i) => (
|
||||||
|
<div key={i} className={styles["mask-row"]}>
|
||||||
|
{masks.map((mask, index) => (
|
||||||
|
<MaskItem
|
||||||
|
key={index}
|
||||||
|
mask={mask}
|
||||||
|
onClick={() => startChat(mask)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 852 B |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 573 B |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,26 @@
|
|||||||
|
import { Mask } from "../store/mask";
|
||||||
|
import { CN_MASKS } from "./cn";
|
||||||
|
import { EN_MASKS } from "./en";
|
||||||
|
|
||||||
|
import { type BuiltinMask } from "./typing";
|
||||||
|
export { type BuiltinMask } from "./typing";
|
||||||
|
|
||||||
|
export const BUILTIN_MASK_ID = 100000;
|
||||||
|
|
||||||
|
export const BUILTIN_MASK_STORE = {
|
||||||
|
buildinId: BUILTIN_MASK_ID,
|
||||||
|
masks: {} as Record<number, Mask>,
|
||||||
|
get(id?: number) {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return this.masks[id] as Mask | undefined;
|
||||||
|
},
|
||||||
|
add(m: BuiltinMask) {
|
||||||
|
const mask = { ...m, id: this.buildinId++ };
|
||||||
|
this.masks[mask.id] = mask;
|
||||||
|
return mask;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUILTIN_MASKS: Mask[] = [...CN_MASKS, ...EN_MASKS].map((m) =>
|
||||||
|
BUILTIN_MASK_STORE.add(m),
|
||||||
|
);
|
@ -0,0 +1,3 @@
|
|||||||
|
import { type Mask } from "../store/mask";
|
||||||
|
|
||||||
|
export type BuiltinMask = Omit<Mask, "id">;
|
@ -1,4 +1,4 @@
|
|||||||
export * from "./app";
|
export * from "./chat";
|
||||||
export * from "./update";
|
export * from "./update";
|
||||||
export * from "./access";
|
export * from "./access";
|
||||||
export * from "./config";
|
export * from "./config";
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { BUILTIN_MASKS } from "../masks";
|
||||||
|
import { getLang, Lang } from "../locales";
|
||||||
|
import { DEFAULT_TOPIC, Message } from "./chat";
|
||||||
|
import { ModelConfig, ModelType, useAppConfig } from "./config";
|
||||||
|
import { StoreKey } from "../constant";
|
||||||
|
|
||||||
|
export type Mask = {
|
||||||
|
id: number;
|
||||||
|
avatar: string;
|
||||||
|
name: string;
|
||||||
|
context: Message[];
|
||||||
|
modelConfig: ModelConfig;
|
||||||
|
lang: Lang;
|
||||||
|
builtin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MASK_STATE = {
|
||||||
|
masks: {} as Record<number, Mask>,
|
||||||
|
globalMaskId: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MaskState = typeof DEFAULT_MASK_STATE;
|
||||||
|
type MaskStore = MaskState & {
|
||||||
|
create: (mask?: Partial<Mask>) => Mask;
|
||||||
|
update: (id: number, updater: (mask: Mask) => void) => void;
|
||||||
|
delete: (id: number) => void;
|
||||||
|
search: (text: string) => Mask[];
|
||||||
|
get: (id?: number) => Mask | null;
|
||||||
|
getAll: () => Mask[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MASK_ID = 1145141919810;
|
||||||
|
export const DEFAULT_MASK_AVATAR = "gpt-bot";
|
||||||
|
export const createEmptyMask = () =>
|
||||||
|
({
|
||||||
|
id: DEFAULT_MASK_ID,
|
||||||
|
avatar: DEFAULT_MASK_AVATAR,
|
||||||
|
name: DEFAULT_TOPIC,
|
||||||
|
context: [],
|
||||||
|
modelConfig: { ...useAppConfig.getState().modelConfig },
|
||||||
|
lang: getLang(),
|
||||||
|
builtin: false,
|
||||||
|
} as Mask);
|
||||||
|
|
||||||
|
export const useMaskStore = create<MaskStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...DEFAULT_MASK_STATE,
|
||||||
|
|
||||||
|
create(mask) {
|
||||||
|
set(() => ({ globalMaskId: get().globalMaskId + 1 }));
|
||||||
|
const id = get().globalMaskId;
|
||||||
|
const masks = get().masks;
|
||||||
|
masks[id] = {
|
||||||
|
...createEmptyMask(),
|
||||||
|
...mask,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(() => ({ masks }));
|
||||||
|
|
||||||
|
return masks[id];
|
||||||
|
},
|
||||||
|
update(id, updater) {
|
||||||
|
const masks = get().masks;
|
||||||
|
const mask = masks[id];
|
||||||
|
if (!mask) return;
|
||||||
|
const updateMask = { ...mask };
|
||||||
|
updater(updateMask);
|
||||||
|
masks[id] = updateMask;
|
||||||
|
set(() => ({ masks }));
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
const masks = get().masks;
|
||||||
|
delete masks[id];
|
||||||
|
set(() => ({ masks }));
|
||||||
|
},
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
return get().masks[id ?? 1145141919810];
|
||||||
|
},
|
||||||
|
getAll() {
|
||||||
|
const userMasks = Object.values(get().masks).sort(
|
||||||
|
(a, b) => b.id - a.id,
|
||||||
|
);
|
||||||
|
return userMasks.concat(BUILTIN_MASKS);
|
||||||
|
},
|
||||||
|
search(text) {
|
||||||
|
return Object.values(get().masks);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: StoreKey.Mask,
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 64 KiB |