Browse Source

feat: #2 add prompt hints

Yifei Zhang 1 year ago
parent
commit
6782e65fdf

+ 56 - 2
app/components/home.module.scss

@@ -333,11 +333,65 @@
 
 .chat-input-panel {
   position: absolute;
-  bottom: 20px;
+  bottom: 0px;
   display: flex;
   width: 100%;
   padding: 20px;
   box-sizing: border-box;
+  flex-direction: column;
+}
+
+@mixin single-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.prompt-hints {
+  min-height: 20px;
+  width: 100%;
+  max-height: 50vh;
+  overflow: auto;
+  display: flex;
+  flex-direction: column-reverse;
+
+  background-color: var(--white);
+  border: var(--border-in-light);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  box-shadow: var(--shadow);
+
+  .prompt-hint {
+    color: var(--black);
+    padding: 6px 10px;
+    animation: slide-in ease 0.3s;
+    cursor: pointer;
+    transition: all ease 0.3s;
+    border: transparent 1px solid;
+    margin: 4px;
+    border-radius: 8px;
+
+    &:not(:last-child) {
+      margin-top: 0;
+    }
+
+    .hint-title {
+      font-size: 12px;
+      font-weight: bolder;
+
+      @include single-line();
+    }
+    .hint-content {
+      font-size: 12px;
+
+      @include single-line();
+    }
+
+    &-selected,
+    &:hover {
+      border-color: var(--primary);
+    }
+  }
 }
 
 .chat-input-panel-inner {
@@ -375,7 +429,7 @@
 
   position: absolute;
   right: 30px;
-  bottom: 10px;
+  bottom: 30px;
 }
 
 .export-content {

+ 72 - 10
app/components/home.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { useState, useRef, useEffect, useLayoutEffect } from "react";
+import { useDebouncedCallback } from "use-debounce";
 
 import { IconButton } from "./button";
 import styles from "./home.module.scss";
@@ -28,6 +29,7 @@ import Locale from "../locales";
 import dynamic from "next/dynamic";
 import { REPO_URL } from "../constant";
 import { ControllerPool } from "../requests";
+import { Prompt, usePromptStore } from "../store/prompt";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -146,24 +148,77 @@ function useSubmitHandler() {
   };
 }
 
+export function PromptHints(props: {
+  prompts: Prompt[];
+  onPromptSelect: (prompt: Prompt) => void;
+}) {
+  if (props.prompts.length === 0) return null;
+
+  return (
+    <div className={styles["prompt-hints"]}>
+      {props.prompts.map((prompt, i) => (
+        <div
+          className={styles["prompt-hint"]}
+          key={prompt.title + i.toString()}
+          onClick={() => props.onPromptSelect(prompt)}
+        >
+          <div className={styles["hint-title"]}>{prompt.title}</div>
+          <div className={styles["hint-content"]}>{prompt.content}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
 export function Chat(props: { showSideBar?: () => void }) {
   type RenderMessage = Message & { preview?: boolean };
 
+  const chatStore = useChatStore();
   const [session, sessionIndex] = useChatStore((state) => [
     state.currentSession(),
     state.currentSessionIndex,
   ]);
+
+  const inputRef = useRef<HTMLTextAreaElement>(null);
   const [userInput, setUserInput] = useState("");
   const [isLoading, setIsLoading] = useState(false);
   const { submitKey, shouldSubmit } = useSubmitHandler();
 
-  const onUserInput = useChatStore((state) => state.onUserInput);
+  // prompt hints
+  const promptStore = usePromptStore();
+  const [promptHints, setPromptHints] = useState<Prompt[]>([]);
+  const onSearch = useDebouncedCallback(
+    (text: string) => {
+      if (chatStore.config.disablePromptHint) return;
+      setPromptHints(promptStore.search(text));
+    },
+    100,
+    { leading: true, trailing: true }
+  );
+
+  const onPromptSelect = (prompt: Prompt) => {
+    setUserInput(prompt.content);
+    setPromptHints([]);
+    inputRef.current?.focus();
+  };
+
+  // only search prompts when user input is short
+  const SEARCH_TEXT_LIMIT = 10;
+  const onInput = (text: string) => {
+    setUserInput(text);
+    const n = text.trim().length;
+    if (n === 0 || n > SEARCH_TEXT_LIMIT) {
+      setPromptHints([]);
+    } else {
+      onSearch(text);
+    }
+  };
 
   // submit user input
   const onUserSubmit = () => {
     if (userInput.length <= 0) return;
     setIsLoading(true);
-    onUserInput(userInput).then(() => setIsLoading(false));
+    chatStore.onUserInput(userInput).then(() => setIsLoading(false));
     setUserInput("");
     inputRef.current?.focus();
   };
@@ -198,7 +253,9 @@ export function Chat(props: { showSideBar?: () => void }) {
     for (let i = botIndex; i >= 0; i -= 1) {
       if (messages[i].role === "user") {
         setIsLoading(true);
-        onUserInput(messages[i].content).then(() => setIsLoading(false));
+        chatStore
+          .onUserInput(messages[i].content)
+          .then(() => setIsLoading(false));
         return;
       }
     }
@@ -206,7 +263,6 @@ export function Chat(props: { showSideBar?: () => void }) {
 
   // for auto-scroll
   const latestMessageRef = useRef<HTMLDivElement>(null);
-  const inputRef = useRef<HTMLTextAreaElement>(null);
 
   // wont scroll while hovering messages
   const [autoScroll, setAutoScroll] = useState(false);
@@ -373,17 +429,21 @@ export function Chat(props: { showSideBar?: () => void }) {
       </div>
 
       <div className={styles["chat-input-panel"]}>
+        <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
         <div className={styles["chat-input-panel-inner"]}>
           <textarea
             ref={inputRef}
             className={styles["chat-input"]}
             placeholder={Locale.Chat.Input(submitKey)}
-            rows={3}
-            onInput={(e) => setUserInput(e.currentTarget.value)}
+            rows={4}
+            onInput={(e) => onInput(e.currentTarget.value)}
             value={userInput}
             onKeyDown={(e) => onInputKeyDown(e as any)}
             onFocus={() => setAutoScroll(true)}
-            onBlur={() => setAutoScroll(false)}
+            onBlur={() => {
+              setAutoScroll(false);
+              setTimeout(() => setPromptHints([]), 100);
+            }}
             autoFocus
           />
           <IconButton
@@ -411,9 +471,11 @@ function useSwitchTheme() {
       document.body.classList.add("light");
     }
 
-    const themeColor = getComputedStyle(document.body).getPropertyValue("--theme-color").trim();
+    const themeColor = getComputedStyle(document.body)
+      .getPropertyValue("--theme-color")
+      .trim();
     const metaDescription = document.querySelector('meta[name="theme-color"]');
-    metaDescription?.setAttribute('content', themeColor);
+    metaDescription?.setAttribute("content", themeColor);
   }, [config.theme]);
 }
 
@@ -566,7 +628,7 @@ export function Home() {
             <IconButton
               icon={<AddIcon />}
               text={Locale.Home.NewChat}
-              onClick={()=>{
+              onClick={() => {
                 createNewSession();
                 setShowSideBar(false);
               }}

+ 39 - 2
app/components/settings.tsx

@@ -7,8 +7,9 @@ import styles from "./settings.module.scss";
 import ResetIcon from "../icons/reload.svg";
 import CloseIcon from "../icons/close.svg";
 import ClearIcon from "../icons/clear.svg";
+import EditIcon from "../icons/edit.svg";
 
-import { List, ListItem, Popover } from "./ui-lib";
+import { List, ListItem, Popover, showToast } from "./ui-lib";
 
 import { IconButton } from "./button";
 import {
@@ -19,12 +20,13 @@ import {
   useUpdateStore,
   useAccessStore,
 } from "../store";
-import { Avatar } from "./home";
+import { Avatar, PromptHints } from "./home";
 
 import Locale, { changeLang, getLang } from "../locales";
 import { getCurrentCommitId } from "../utils";
 import Link from "next/link";
 import { UPDATE_URL } from "../constant";
+import { SearchService, usePromptStore } from "../store/prompt";
 
 function SettingItem(props: {
   title: string;
@@ -78,6 +80,10 @@ export function Settings(props: { closeSettings: () => void }) {
     []
   );
 
+  const promptStore = usePromptStore();
+  const builtinCount = SearchService.count.builtin;
+  const customCount = promptStore.prompts.size ?? 0;
+
   return (
     <>
       <div className={styles["window-header"]}>
@@ -242,6 +248,37 @@ export function Settings(props: { closeSettings: () => void }) {
             </SettingItem>
           </div>
         </List>
+        <List>
+          <SettingItem
+            title={Locale.Settings.Prompt.Disable.Title}
+            subTitle={Locale.Settings.Prompt.Disable.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={config.disablePromptHint}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.disablePromptHint = e.currentTarget.checked)
+                )
+              }
+            ></input>
+          </SettingItem>
+
+          <SettingItem
+            title={Locale.Settings.Prompt.List}
+            subTitle={Locale.Settings.Prompt.ListCount(
+              builtinCount,
+              customCount
+            )}
+          >
+            <IconButton
+              icon={<EditIcon />}
+              text={Locale.Settings.Prompt.Edit}
+              onClick={() => showToast(Locale.WIP)}
+            />
+          </SettingItem>
+        </List>
         <List>
           {enabledAccessControl ? (
             <SettingItem

+ 1 - 1
app/components/ui-lib.tsx

@@ -36,7 +36,7 @@ export function ListItem(props: { children: JSX.Element[] }) {
   return <div className={styles["list-item"]}>{props.children}</div>;
 }
 
-export function List(props: { children: JSX.Element[] }) {
+export function List(props: { children: JSX.Element[] | JSX.Element }) {
   return <div className={styles.list}>{props.children}</div>;
 }
 

+ 1 - 0
app/icons/edit.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(10.5 11)  rotate(0 1.4166666666666665 1.8333333333333333)" d="M2.83,0L2.83,3C2.83,3.37 2.53,3.67 2.17,3.67L0,3.67 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2.6666666666666665 1.3333333333333333)  rotate(0 5.333333333333333 6.666666666666666)" d="M10.67,4L10.67,0.67C10.67,0.3 10.37,0 10,0L0.67,0C0.3,0 0,0.3 0,0.67L0,12.67C0,13.03 0.3,13.33 0.67,13.33L2.67,13.33 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 5.333333333333333)  rotate(0 2.333333333333333 0)" d="M0,0L4.67,0 " /><path  id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(7.666666666666666 7.666666666666666)  rotate(0 2.833333333333333 3.5)" d="M0,7L5.67,0 " /><path  id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 8)  rotate(0 1.3333333333333333 0)" d="M0,0L2.67,0 " /></g></g></svg>

+ 10 - 0
app/locales/cn.ts

@@ -62,6 +62,16 @@ const cn = {
     SendKey: "发送键",
     Theme: "主题",
     TightBorder: "紧凑边框",
+    Prompt: {
+      Disable: {
+        Title: "禁用提示词自动补全",
+        SubTitle: "禁用后将无法自动根据输入补全",
+      },
+      List: "自定义提示词列表",
+      ListCount: (builtin: number, custom: number) =>
+        `内置 ${builtin} 条,用户定义 ${custom} 条`,
+      Edit: "编辑",
+    },
     HistoryCount: {
       Title: "附带历史消息数",
       SubTitle: "每次请求携带的历史消息数",

+ 4 - 0
app/store/app.ts

@@ -40,6 +40,8 @@ export interface ChatConfig {
   theme: Theme;
   tightBorder: boolean;
 
+  disablePromptHint: boolean;
+
   modelConfig: {
     model: string;
     temperature: number;
@@ -124,6 +126,8 @@ const DEFAULT_CONFIG: ChatConfig = {
   theme: Theme.Auto as Theme,
   tightBorder: false,
 
+  disablePromptHint: false,
+
   modelConfig: {
     model: "gpt-3.5-turbo",
     temperature: 1,

+ 44 - 21
app/store/prompt.ts

@@ -1,10 +1,10 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
-import JsSearch from "js-search";
+import Fuse from "fuse.js";
+import { showToast } from "../components/ui-lib";
 
 export interface Prompt {
   id?: number;
-  shortcut: string;
   title: string;
   content: string;
 }
@@ -22,36 +22,30 @@ export const PROMPT_KEY = "prompt-store";
 
 export const SearchService = {
   ready: false,
-  progress: 0, // 0 - 1, 1 means ready
-  engine: new JsSearch.Search("prompts"),
-  deleted: new Set<number>(),
-
-  async init(prompts: PromptStore["prompts"]) {
-    this.engine.addIndex("id");
-    this.engine.addIndex("shortcut");
-    this.engine.addIndex("title");
-
-    const n = prompts.size;
-    let count = 0;
-    for await (const prompt of prompts.values()) {
-      this.engine.addDocument(prompt);
-      count += 1;
-      this.progress = count / n;
+  engine: new Fuse<Prompt>([], { keys: ["title"] }),
+  count: {
+    builtin: 0,
+  },
+
+  init(prompts: Prompt[]) {
+    if (this.ready) {
+      return;
     }
+    this.engine.setCollection(prompts);
     this.ready = true;
   },
 
   remove(id: number) {
-    this.deleted.add(id);
+    this.engine.remove((doc) => doc.id === id);
   },
 
   add(prompt: Prompt) {
-    this.engine.addDocument(prompt);
+    this.engine.add(prompt);
   },
 
   search(text: string) {
-    const results = this.engine.search(text) as Prompt[];
-    return results.filter((v) => !v.id || !this.deleted.has(v.id));
+    const results = this.engine.search(text);
+    return results.map((v) => v.item);
   },
 };
 
@@ -91,6 +85,35 @@ export const usePromptStore = create<PromptStore>()(
     {
       name: PROMPT_KEY,
       version: 1,
+      onRehydrateStorage(state) {
+        const PROMPT_URL = "./prompts.json";
+
+        type PromptList = Array<[string, string]>;
+
+        fetch(PROMPT_URL)
+          .then((res) => res.json())
+          .then((res) => {
+            const builtinPrompts = [res.en, res.cn]
+              .map((promptList: PromptList) => {
+                return promptList.map(
+                  ([title, content]) =>
+                    ({
+                      title,
+                      content,
+                    } as Prompt)
+                );
+              })
+              .concat([...(state?.prompts?.values() ?? [])]);
+
+            const allPromptsForSearch = builtinPrompts.reduce(
+              (pre, cur) => pre.concat(cur),
+              []
+            );
+            SearchService.count.builtin = res.en.length + res.cn.length;
+            SearchService.init(allPromptsForSearch);
+            showToast(`已加载 ${allPromptsForSearch.length} 条 Prompts`);
+          });
+      },
     }
   )
 );

+ 2 - 2
package.json

@@ -12,7 +12,6 @@
   },
   "dependencies": {
     "@svgr/webpack": "^6.5.1",
-    "@types/js-search": "^1.4.0",
     "@types/node": "^18.14.6",
     "@types/react": "^18.0.28",
     "@types/react-dom": "^18.0.11",
@@ -24,7 +23,7 @@
     "eslint": "8.35.0",
     "eslint-config-next": "13.2.3",
     "eventsource-parser": "^0.1.0",
-    "js-search": "^2.0.0",
+    "fuse.js": "^6.6.2",
     "next": "^13.2.3",
     "node-fetch": "^3.3.1",
     "openai": "^3.2.1",
@@ -38,6 +37,7 @@
     "sass": "^1.59.2",
     "spark-md5": "^3.0.2",
     "typescript": "4.9.5",
+    "use-debounce": "^9.0.3",
     "zustand": "^4.3.6"
   }
 }

+ 10 - 10
yarn.lock

@@ -1320,11 +1320,6 @@
   dependencies:
     "@types/unist" "*"
 
-"@types/js-search@^1.4.0":
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/@types/js-search/-/js-search-1.4.0.tgz#f2d4afa176a4fc7b17fb46a1593847887fa1fb7b"
-  integrity sha512-OMDWvQP2AmxpQI9vFh7U/TzExNGB9Sj9WQCoxUR8VXZEv6jM4cyNzLODkh1gkBHJ9Er7kdasChzEpba4FxLGaA==
-
 "@types/json5@^0.0.29":
   version "0.0.29"
   resolved "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@@ -2524,6 +2519,11 @@ functions-have-names@^1.2.2:
   resolved "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
   integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
 
+fuse.js@^6.6.2:
+  version "6.6.2"
+  resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
+  integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
+
 gensync@^1.0.0-beta.2:
   version "1.0.0-beta.2"
   resolved "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -3042,11 +3042,6 @@ js-sdsl@^4.1.4:
   resolved "https://registry.npmmirror.com/js-sdsl/-/js-sdsl-4.3.0.tgz#aeefe32a451f7af88425b11fdb5f58c90ae1d711"
   integrity sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==
 
-js-search@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/js-search/-/js-search-2.0.0.tgz#84dc9d44e34ca0870d067e04b86d8038b77edc26"
-  integrity sha512-lJ8KzjlwcelIWuAdKyzsXv45W6OIwRpayzc7XmU8mzgWadg5UVOKVmnq/tXudddEB9Ceic3tVaGu6QOK/eebhg==
-
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -4660,6 +4655,11 @@ uri-js@^4.2.2:
   dependencies:
     punycode "^2.1.0"
 
+use-debounce@^9.0.3:
+  version "9.0.3"
+  resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.3.tgz#bac660c19ab7b38662e08608fee23c7ad303f532"
+  integrity sha512-FhtlbDtDXILJV7Lix5OZj5yX/fW1tzq+VrvK1fnT2bUrPOGruU9Rw8NCEn+UI9wopfERBEZAOQ8lfeCJPllgnw==
+
 use-sync-external-store@1.2.0:
   version "1.2.0"
   resolved "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"