ソースを参照

Merge pull request #882 from Yidadaa/bugfix-0417

feat: user prompts
Yifei Zhang 2 年 前
コミット
b7b16aa33b

+ 2 - 3
app/components/chat.tsx

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

+ 60 - 0
app/components/settings.module.scss

@@ -32,3 +32,63 @@
     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 {
+  }
+}

+ 121 - 16
app/components/settings.tsx

@@ -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";
 
@@ -6,12 +6,13 @@ import styles from "./settings.module.scss";
 
 import ResetIcon from "../icons/reload.svg";
 import CloseIcon from "../icons/close.svg";
+import CopyIcon from "../icons/copy.svg";
 import ClearIcon from "../icons/clear.svg";
 import EditIcon from "../icons/edit.svg";
 import EyeIcon from "../icons/eye.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 {
@@ -26,14 +27,114 @@ import {
 import { Avatar } from "./chat";
 
 import Locale, { AllLangs, changeLang, getLang } from "../locales";
-import { getEmojiUrl } from "../utils";
+import { copyToClipboard, getEmojiUrl } from "../utils";
 import Link from "next/link";
 import { UPDATE_URL } from "../constant";
-import { SearchService, usePromptStore } from "../store/prompt";
-import { requestUsage } from "../requests";
+import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { ErrorBoundary } from "./error";
 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: {
   title: string;
   subTitle?: string;
@@ -99,18 +200,16 @@ export function Settings(props: { closeSettings: () => void }) {
     });
   }
 
-  const [usage, setUsage] = useState<{
-    used?: number;
-    subscription?: number;
-  }>();
+  const usage = {
+    used: updateStore.used,
+    subscription: updateStore.subscription,
+  };
   const [loadingUsage, setLoadingUsage] = useState(false);
   function checkUsage() {
     setLoadingUsage(true);
-    requestUsage()
-      .then((res) => setUsage(res))
-      .finally(() => {
-        setLoadingUsage(false);
-      });
+    updateStore.updateUsage().finally(() => {
+      setLoadingUsage(false);
+    });
   }
 
   const accessStore = useAccessStore();
@@ -122,10 +221,12 @@ export function Settings(props: { closeSettings: () => void }) {
 
   const promptStore = usePromptStore();
   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();
   useEffect(() => {
+    // checks per minutes
     checkUpdate();
     showUsage && checkUsage();
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -469,7 +570,7 @@ export function Settings(props: { closeSettings: () => void }) {
             <IconButton
               icon={<EditIcon />}
               text={Locale.Settings.Prompt.Edit}
-              onClick={() => showToast(Locale.WIP)}
+              onClick={() => setShowPromptModal(true)}
             />
           </SettingItem>
         </List>
@@ -555,6 +656,10 @@ export function Settings(props: { closeSettings: () => void }) {
             ></InputRange>
           </SettingItem>
         </List>
+
+        {shouldShowPromptModal && (
+          <UserPromptModal onClose={() => setShowPromptModal(false)} />
+        )}
       </div>
     </ErrorBoundary>
   );

+ 1 - 1
app/components/ui-lib.module.scss

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

+ 7 - 2
app/locales/cn.ts

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

+ 5 - 0
app/locales/de.ts

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

+ 6 - 1
app/locales/en.ts

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

+ 5 - 0
app/locales/es.ts

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

+ 5 - 0
app/locales/it.ts

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

+ 5 - 0
app/locales/jp.ts

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

+ 5 - 0
app/locales/tr.ts

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

+ 5 - 0
app/locales/tw.ts

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

+ 1 - 1
app/requests.ts

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

+ 58 - 21
app/store/prompt.ts

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

+ 33 - 10
app/store/update.ts

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

+ 2 - 0
app/styles/globals.scss

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