Merge pull request #882 from Yidadaa/bugfix-0417

feat: user prompts
This commit is contained in:
Yifei Zhang 2023-04-18 01:40:38 +08:00 committed by GitHub
commit b7b16aa33b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 321 additions and 55 deletions

View File

@ -570,10 +570,9 @@ export function Chat(props: {
if (userIndex === null) return; if (userIndex === null) return;
setIsLoading(true); setIsLoading(true);
chatStore const content = session.messages[userIndex].content;
.onUserInput(session.messages[userIndex].content)
.then(() => setIsLoading(false));
deleteMessage(userIndex); deleteMessage(userIndex);
chatStore.onUserInput(content).then(() => setIsLoading(false));
inputRef.current?.focus(); inputRef.current?.focus();
}; };

View File

@ -32,3 +32,63 @@
min-width: 80%; min-width: 80%;
} }
} }
.user-prompt-modal {
min-height: 40vh;
.user-prompt-search {
width: 100%;
max-width: 100%;
margin-bottom: 10px;
background-color: var(--gray);
}
.user-prompt-list {
padding: 10px 0;
.user-prompt-item {
margin-bottom: 10px;
widows: 100%;
.user-prompt-header {
display: flex;
widows: 100%;
margin-bottom: 5px;
.user-prompt-title {
flex-grow: 1;
max-width: 100%;
margin-right: 5px;
padding: 5px;
font-size: 12px;
text-align: left;
}
.user-prompt-buttons {
display: flex;
align-items: center;
.user-prompt-button {
height: 100%;
&:not(:last-child) {
margin-right: 5px;
}
}
}
}
.user-prompt-content {
width: 100%;
box-sizing: border-box;
padding: 5px;
margin-right: 10px;
font-size: 12px;
flex-grow: 1;
}
}
}
.user-prompt-actions {
}
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, HTMLProps } from "react"; import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react"; import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
@ -6,12 +6,13 @@ import styles from "./settings.module.scss";
import ResetIcon from "../icons/reload.svg"; import ResetIcon from "../icons/reload.svg";
import CloseIcon from "../icons/close.svg"; import CloseIcon from "../icons/close.svg";
import CopyIcon from "../icons/copy.svg";
import ClearIcon from "../icons/clear.svg"; import ClearIcon from "../icons/clear.svg";
import EditIcon from "../icons/edit.svg"; import EditIcon from "../icons/edit.svg";
import EyeIcon from "../icons/eye.svg"; import EyeIcon from "../icons/eye.svg";
import EyeOffIcon from "../icons/eye-off.svg"; import EyeOffIcon from "../icons/eye-off.svg";
import { List, ListItem, Popover, showToast } from "./ui-lib"; import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
import { IconButton } from "./button"; import { IconButton } from "./button";
import { import {
@ -26,14 +27,114 @@ import {
import { Avatar } from "./chat"; import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales"; import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getEmojiUrl } from "../utils"; import { copyToClipboard, getEmojiUrl } from "../utils";
import Link from "next/link"; import Link from "next/link";
import { UPDATE_URL } from "../constant"; import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt"; import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { requestUsage } from "../requests";
import { ErrorBoundary } from "./error"; import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range"; import { InputRange } from "./input-range";
function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore();
const userPrompts = promptStore.getUserPrompts();
const builtinPrompts = SearchService.builtinPrompts;
const allPrompts = userPrompts.concat(builtinPrompts);
const [searchInput, setSearchInput] = useState("");
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
useEffect(() => {
if (searchInput.length > 0) {
const searchResult = SearchService.search(searchInput);
setSearchPrompts(searchResult);
} else {
setSearchPrompts([]);
}
}, [searchInput]);
return (
<div className="modal-mask">
<Modal
title={Locale.Settings.Prompt.Modal.Title}
onClose={() => props.onClose?.()}
actions={[
<IconButton
key="add"
onClick={() => promptStore.add({ title: "", content: "" })}
icon={<ClearIcon />}
bordered
text={Locale.Settings.Prompt.Modal.Add}
/>,
]}
>
<div className={styles["user-prompt-modal"]}>
<input
type="text"
className={styles["user-prompt-search"]}
placeholder={Locale.Settings.Prompt.Modal.Search}
value={searchInput}
onInput={(e) => setSearchInput(e.currentTarget.value)}
></input>
<div className={styles["user-prompt-list"]}>
{prompts.map((v, _) => (
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
<div className={styles["user-prompt-header"]}>
<input
type="text"
className={styles["user-prompt-title"]}
value={v.title}
readOnly={!v.isUser}
onChange={(e) => {
if (v.isUser) {
promptStore.updateUserPrompts(
v.id!,
(prompt) => (prompt.title = e.currentTarget.value),
);
}
}}
></input>
<div className={styles["user-prompt-buttons"]}>
{v.isUser && (
<IconButton
icon={<ClearIcon />}
bordered
className={styles["user-prompt-button"]}
onClick={() => promptStore.remove(v.id!)}
/>
)}
<IconButton
icon={<CopyIcon />}
bordered
className={styles["user-prompt-button"]}
onClick={() => copyToClipboard(v.content)}
/>
</div>
</div>
<Input
rows={2}
value={v.content}
className={styles["user-prompt-content"]}
readOnly={!v.isUser}
onChange={(e) => {
if (v.isUser) {
promptStore.updateUserPrompts(
v.id!,
(prompt) => (prompt.content = e.currentTarget.value),
);
}
}}
/>
</div>
))}
</div>
</div>
</Modal>
</div>
);
}
function SettingItem(props: { function SettingItem(props: {
title: string; title: string;
subTitle?: string; subTitle?: string;
@ -99,18 +200,16 @@ export function Settings(props: { closeSettings: () => void }) {
}); });
} }
const [usage, setUsage] = useState<{ const usage = {
used?: number; used: updateStore.used,
subscription?: number; subscription: updateStore.subscription,
}>(); };
const [loadingUsage, setLoadingUsage] = useState(false); const [loadingUsage, setLoadingUsage] = useState(false);
function checkUsage() { function checkUsage() {
setLoadingUsage(true); setLoadingUsage(true);
requestUsage() updateStore.updateUsage().finally(() => {
.then((res) => setUsage(res)) setLoadingUsage(false);
.finally(() => { });
setLoadingUsage(false);
});
} }
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -122,10 +221,12 @@ export function Settings(props: { closeSettings: () => void }) {
const promptStore = usePromptStore(); const promptStore = usePromptStore();
const builtinCount = SearchService.count.builtin; const builtinCount = SearchService.count.builtin;
const customCount = promptStore.prompts.size ?? 0; const customCount = promptStore.getUserPrompts().length ?? 0;
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
const showUsage = accessStore.isAuthorized(); const showUsage = accessStore.isAuthorized();
useEffect(() => { useEffect(() => {
// checks per minutes
checkUpdate(); checkUpdate();
showUsage && checkUsage(); showUsage && checkUsage();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -469,7 +570,7 @@ export function Settings(props: { closeSettings: () => void }) {
<IconButton <IconButton
icon={<EditIcon />} icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit} text={Locale.Settings.Prompt.Edit}
onClick={() => showToast(Locale.WIP)} onClick={() => setShowPromptModal(true)}
/> />
</SettingItem> </SettingItem>
</List> </List>
@ -555,6 +656,10 @@ export function Settings(props: { closeSettings: () => void }) {
></InputRange> ></InputRange>
</SettingItem> </SettingItem>
</List> </List>
{shouldShowPromptModal && (
<UserPromptModal onClose={() => setShowPromptModal(false)} />
)}
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@ -53,7 +53,7 @@
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
background-color: var(--white); background-color: var(--white);
border-radius: 12px; border-radius: 12px;
width: 50vw; width: 60vw;
animation: slide-in ease 0.3s; animation: slide-in ease 0.3s;
--modal-padding: 20px; --modal-padding: 20px;

View File

@ -59,10 +59,10 @@ const cn = {
ResetAll: "重置所有选项", ResetAll: "重置所有选项",
Close: "关闭", Close: "关闭",
ConfirmResetAll: { ConfirmResetAll: {
Confirm: "Are you sure you want to reset all configurations?", Confirm: "确认清除所有配置?",
}, },
ConfirmClearAll: { ConfirmClearAll: {
Confirm: "Are you sure you want to reset all chat?", Confirm: "确认清除所有聊天记录?",
}, },
}, },
Lang: { Lang: {
@ -105,6 +105,11 @@ const cn = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`内置 ${builtin} 条,用户定义 ${custom}`, `内置 ${builtin} 条,用户定义 ${custom}`,
Edit: "编辑", Edit: "编辑",
Modal: {
Title: "提示词列表",
Add: "增加一条",
Search: "搜尋提示詞",
},
}, },
HistoryCount: { HistoryCount: {
Title: "附带历史消息数", Title: "附带历史消息数",

View File

@ -107,6 +107,11 @@ const de: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`${builtin} integriert, ${custom} benutzerdefiniert`, `${builtin} integriert, ${custom} benutzerdefiniert`,
Edit: "Bearbeiten", Edit: "Bearbeiten",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
}, },
HistoryCount: { HistoryCount: {
Title: "Anzahl der angehängten Nachrichten", Title: "Anzahl der angehängten Nachrichten",

View File

@ -107,6 +107,11 @@ const en: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`, `${builtin} built-in, ${custom} user-defined`,
Edit: "Edit", Edit: "Edit",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
}, },
HistoryCount: { HistoryCount: {
Title: "Attached Messages Count", Title: "Attached Messages Count",
@ -128,7 +133,7 @@ const en: LocaleType = {
return `Used this month $${used}, subscription $${total}`; return `Used this month $${used}, subscription $${total}`;
}, },
IsChecking: "Checking...", IsChecking: "Checking...",
Check: "Check Again", Check: "Check",
NoAccess: "Enter API Key to check balance", NoAccess: "Enter API Key to check balance",
}, },
AccessCode: { AccessCode: {

View File

@ -107,6 +107,11 @@ const es: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`${builtin} incorporado, ${custom} definido por el usuario`, `${builtin} incorporado, ${custom} definido por el usuario`,
Edit: "Editar", Edit: "Editar",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
}, },
HistoryCount: { HistoryCount: {
Title: "Cantidad de mensajes adjuntos", Title: "Cantidad de mensajes adjuntos",

View File

@ -107,6 +107,11 @@ const it: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`, `${builtin} built-in, ${custom} user-defined`,
Edit: "Modifica", Edit: "Modifica",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
}, },
HistoryCount: { HistoryCount: {
Title: "Conteggio dei messaggi allegati", Title: "Conteggio dei messaggi allegati",

View File

@ -108,6 +108,11 @@ const jp = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`組み込み ${builtin} 件、ユーザー定義 ${custom}`, `組み込み ${builtin} 件、ユーザー定義 ${custom}`,
Edit: "編集", Edit: "編集",
Modal: {
Title: "提示词列表",
Add: "增加一条",
Search: "搜尋提示詞",
},
}, },
HistoryCount: { HistoryCount: {
Title: "履歴メッセージ数を添付", Title: "履歴メッセージ数を添付",

View File

@ -107,6 +107,11 @@ const tr: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`${builtin} yerleşik, ${custom} kullanıcı tanımlı`, `${builtin} yerleşik, ${custom} kullanıcı tanımlı`,
Edit: "Düzenle", Edit: "Düzenle",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
}, },
HistoryCount: { HistoryCount: {
Title: "Ekli Mesaj Sayısı", Title: "Ekli Mesaj Sayısı",

View File

@ -105,6 +105,11 @@ const tw: LocaleType = {
ListCount: (builtin: number, custom: number) => ListCount: (builtin: number, custom: number) =>
`內置 ${builtin} 條,用戶定義 ${custom}`, `內置 ${builtin} 條,用戶定義 ${custom}`,
Edit: "編輯", Edit: "編輯",
Modal: {
Title: "提示詞列表",
Add: "增加一條",
Search: "搜索提示词",
},
}, },
HistoryCount: { HistoryCount: {
Title: "附帶歷史訊息數", Title: "附帶歷史訊息數",

View File

@ -113,7 +113,7 @@ export async function requestUsage() {
if (response.total_usage) { if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100; response.total_usage = Math.round(response.total_usage) / 100;
} }
if (total.hard_limit_usd) { if (total.hard_limit_usd) {
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100; total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
} }

View File

@ -5,62 +5,74 @@ import { getLang } from "../locales";
export interface Prompt { export interface Prompt {
id?: number; id?: number;
isUser?: boolean;
title: string; title: string;
content: string; content: string;
} }
export interface PromptStore { export interface PromptStore {
counter: number;
latestId: number; latestId: number;
prompts: Map<number, Prompt>; prompts: Record<number, Prompt>;
add: (prompt: Prompt) => number; add: (prompt: Prompt) => number;
remove: (id: number) => void; remove: (id: number) => void;
search: (text: string) => Prompt[]; search: (text: string) => Prompt[];
getUserPrompts: () => Prompt[];
updateUserPrompts: (id: number, updater: (prompt: Prompt) => void) => void;
} }
export const PROMPT_KEY = "prompt-store"; export const PROMPT_KEY = "prompt-store";
export const SearchService = { export const SearchService = {
ready: false, ready: false,
engine: new Fuse<Prompt>([], { keys: ["title"] }), builtinEngine: new Fuse<Prompt>([], { keys: ["title"] }),
userEngine: new Fuse<Prompt>([], { keys: ["title"] }),
count: { count: {
builtin: 0, builtin: 0,
}, },
allBuiltInPrompts: [] as Prompt[], allPrompts: [] as Prompt[],
builtinPrompts: [] as Prompt[],
init(prompts: Prompt[]) { init(builtinPrompts: Prompt[], userPrompts: Prompt[]) {
if (this.ready) { if (this.ready) {
return; return;
} }
this.allBuiltInPrompts = prompts; this.allPrompts = userPrompts.concat(builtinPrompts);
this.engine.setCollection(prompts); this.builtinPrompts = builtinPrompts.slice();
this.builtinEngine.setCollection(builtinPrompts);
this.userEngine.setCollection(userPrompts);
this.ready = true; this.ready = true;
}, },
remove(id: number) { remove(id: number) {
this.engine.remove((doc) => doc.id === id); this.userEngine.remove((doc) => doc.id === id);
}, },
add(prompt: Prompt) { add(prompt: Prompt) {
this.engine.add(prompt); this.userEngine.add(prompt);
}, },
search(text: string) { search(text: string) {
const results = this.engine.search(text); const userResults = this.userEngine.search(text);
return results.map((v) => v.item); const builtinResults = this.builtinEngine.search(text);
return userResults.concat(builtinResults).map((v) => v.item);
}, },
}; };
export const usePromptStore = create<PromptStore>()( export const usePromptStore = create<PromptStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
counter: 0,
latestId: 0, latestId: 0,
prompts: new Map(), prompts: {},
add(prompt) { add(prompt) {
const prompts = get().prompts; const prompts = get().prompts;
prompt.id = get().latestId + 1; prompt.id = get().latestId + 1;
prompts.set(prompt.id, prompt); prompt.isUser = true;
prompts[prompt.id] = prompt;
set(() => ({ set(() => ({
latestId: prompt.id!, latestId: prompt.id!,
@ -72,19 +84,40 @@ export const usePromptStore = create<PromptStore>()(
remove(id) { remove(id) {
const prompts = get().prompts; const prompts = get().prompts;
prompts.delete(id); delete prompts[id];
SearchService.remove(id); SearchService.remove(id);
set(() => ({ set(() => ({
prompts, prompts,
counter: get().counter + 1,
})); }));
}, },
getUserPrompts() {
const userPrompts = Object.values(get().prompts ?? {});
userPrompts.sort((a, b) => (b.id && a.id ? b.id - a.id : 0));
return userPrompts;
},
updateUserPrompts(id: number, updater) {
const prompt = get().prompts[id] ?? {
title: "",
content: "",
id,
};
SearchService.remove(id);
updater(prompt);
const prompts = get().prompts;
prompts[id] = prompt;
set(() => ({ prompts }));
SearchService.add(prompt);
},
search(text) { search(text) {
if (text.length === 0) { if (text.length === 0) {
// return all prompts // return all rompts
const userPrompts = get().prompts?.values?.() ?? []; return SearchService.allPrompts.concat([...get().getUserPrompts()]);
return SearchService.allBuiltInPrompts.concat([...userPrompts]);
} }
return SearchService.search(text) as Prompt[]; return SearchService.search(text) as Prompt[];
}, },
@ -104,23 +137,27 @@ export const usePromptStore = create<PromptStore>()(
if (getLang() === "cn") { if (getLang() === "cn") {
fetchPrompts = fetchPrompts.reverse(); fetchPrompts = fetchPrompts.reverse();
} }
const builtinPrompts = fetchPrompts const builtinPrompts = fetchPrompts.map(
.map((promptList: PromptList) => { (promptList: PromptList) => {
return promptList.map( return promptList.map(
([title, content]) => ([title, content]) =>
({ ({
id: Math.random(),
title, title,
content, content,
} as Prompt), } as Prompt),
); );
}) },
.concat([...(state?.prompts?.values() ?? [])]); );
const userPrompts =
usePromptStore.getState().getUserPrompts() ?? [];
const allPromptsForSearch = builtinPrompts const allPromptsForSearch = builtinPrompts
.reduce((pre, cur) => pre.concat(cur), []) .reduce((pre, cur) => pre.concat(cur), [])
.filter((v) => !!v.title && !!v.content); .filter((v) => !!v.title && !!v.content);
SearchService.count.builtin = res.en.length + res.cn.length; SearchService.count.builtin = res.en.length + res.cn.length;
SearchService.init(allPromptsForSearch); SearchService.init(allPromptsForSearch, userPrompts);
}); });
}, },
}, },

View File

@ -1,13 +1,19 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant"; import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant";
import { requestUsage } from "../requests";
export interface UpdateStore { export interface UpdateStore {
lastUpdate: number; lastUpdate: number;
remoteVersion: string; remoteVersion: string;
used?: number;
subscription?: number;
lastUpdateUsage: number;
version: string; version: string;
getLatestVersion: (force: boolean) => Promise<string>; getLatestVersion: (force?: boolean) => Promise<void>;
updateUsage: (force?: boolean) => Promise<void>;
} }
export const UPDATE_KEY = "chat-update"; export const UPDATE_KEY = "chat-update";
@ -26,22 +32,27 @@ function queryMeta(key: string, defaultValue?: string): string {
return ret; return ret;
} }
const ONE_MINUTE = 60 * 1000;
export const useUpdateStore = create<UpdateStore>()( export const useUpdateStore = create<UpdateStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
lastUpdate: 0, lastUpdate: 0,
remoteVersion: "", remoteVersion: "",
lastUpdateUsage: 0,
version: "unknown", version: "unknown",
async getLatestVersion(force = false) { async getLatestVersion(force = false) {
set(() => ({ version: queryMeta("version") })); set(() => ({ version: queryMeta("version") ?? "unknown" }));
const overTenMins = Date.now() - get().lastUpdate > 10 * 60 * 1000; const overTenMins = Date.now() - get().lastUpdate > 10 * ONE_MINUTE;
const shouldFetch = force || overTenMins; if (!force && !overTenMins) return;
if (!shouldFetch) {
return get().version ?? "unknown"; set(() => ({
} lastUpdate: Date.now(),
}));
try { try {
// const data = await (await fetch(FETCH_TAG_URL)).json(); // const data = await (await fetch(FETCH_TAG_URL)).json();
@ -49,14 +60,26 @@ export const useUpdateStore = create<UpdateStore>()(
const data = await (await fetch(FETCH_COMMIT_URL)).json(); const data = await (await fetch(FETCH_COMMIT_URL)).json();
const remoteId = (data[0].sha as string).substring(0, 7); const remoteId = (data[0].sha as string).substring(0, 7);
set(() => ({ set(() => ({
lastUpdate: Date.now(),
remoteVersion: remoteId, remoteVersion: remoteId,
})); }));
console.log("[Got Upstream] ", remoteId); console.log("[Got Upstream] ", remoteId);
return remoteId;
} catch (error) { } catch (error) {
console.error("[Fetch Upstream Commit Id]", error); console.error("[Fetch Upstream Commit Id]", error);
return get().version ?? ""; }
},
async updateUsage(force = false) {
const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE;
if (!overOneMinute && !force) return;
set(() => ({
lastUpdateUsage: Date.now(),
}));
const usage = await requestUsage();
if (usage) {
set(() => usage);
} }
}, },
}), }),

View File

@ -140,6 +140,7 @@ label {
input { input {
text-align: center; text-align: center;
font-family: inherit;
} }
input[type="checkbox"] { input[type="checkbox"] {
@ -224,6 +225,7 @@ input[type="password"] {
color: var(--black); color: var(--black);
padding: 0 10px; padding: 0 10px;
max-width: 50%; max-width: 50%;
font-family: inherit;
} }
div.math { div.math {