Browse Source

feat: #138 add context prompt, close #330 #321

Yifei Zhang 1 year ago
parent
commit
b85245e317

+ 5 - 21
app/components/button.module.scss

@@ -6,19 +6,21 @@
   justify-content: center;
   padding: 10px;
 
-  box-shadow: var(--card-shadow);
   cursor: pointer;
   transition: all 0.3s ease;
   overflow: hidden;
   user-select: none;
 }
 
+.shadow {
+  box-shadow: var(--card-shadow);
+}
+
 .border {
   border: var(--border-in-light);
 }
 
 .icon-button:hover {
-  filter: brightness(0.9);
   border-color: var(--primary);
 }
 
@@ -36,25 +38,7 @@
   }
 }
 
-@mixin dark-button {
-  div:not(:global(.no-dark))>.icon-button-icon {
-    filter: invert(0.5);
-  }
-
-  .icon-button:hover {
-    filter: brightness(1.2);
-  }
-}
-
-:global(.dark) {
-  @include dark-button;
-}
-
-@media (prefers-color-scheme: dark) {
-  @include dark-button;
-}
-
 .icon-button-text {
   margin-left: 5px;
   font-size: 12px;
-}
+}

+ 5 - 1
app/components/button.tsx

@@ -7,6 +7,7 @@ export function IconButton(props: {
   icon: JSX.Element;
   text?: string;
   bordered?: boolean;
+  shadow?: boolean;
   className?: string;
   title?: string;
 }) {
@@ -14,10 +15,13 @@ export function IconButton(props: {
     <div
       className={
         styles["icon-button"] +
-        ` ${props.bordered && styles.border} ${props.className ?? ""}`
+        ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
+          props.className ?? ""
+        } clickable`
       }
       onClick={props.onClick}
       title={props.title}
+      role="button"
     >
       <div className={styles["icon-button-icon"]}>{props.icon}</div>
       {props.text && (

+ 5 - 1
app/components/chat-list.tsx

@@ -11,6 +11,7 @@ import {
 } from "../store";
 
 import Locale from "../locales";
+import { isMobileScreen } from "../utils";
 
 export function ChatItem(props: {
   onClick?: () => void;
@@ -61,7 +62,10 @@ export function ChatList() {
           key={i}
           selected={i === selectedIndex}
           onClick={() => selectSession(i)}
-          onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
+          onDelete={() =>
+            (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
+            removeSession(i)
+          }
         />
       ))}
     </div>

+ 71 - 0
app/components/chat.module.scss

@@ -0,0 +1,71 @@
+.prompt-toast {
+  position: absolute;
+  bottom: -50px;
+  z-index: 999;
+  display: flex;
+  justify-content: center;
+  width: calc(100% - 40px);
+
+  .prompt-toast-inner {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+
+    border: var(--border-in-light);
+    box-shadow: var(--card-shadow);
+    padding: 10px 20px;
+    border-radius: 100px;
+
+    .prompt-toast-content {
+      margin-left: 10px;
+    }
+  }
+}
+
+.context-prompt {
+  .context-prompt-row {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    margin-bottom: 10px;
+
+    .context-role {
+      margin-right: 10px;
+    }
+
+    .context-content {
+      flex: 1;
+      max-width: 100%;
+      text-align: left;
+    }
+
+    .context-delete-button {
+      margin-left: 10px;
+    }
+  }
+
+  .context-prompt-button {
+    flex: 1;
+  }
+}
+
+.memory-prompt {
+  margin-top: 20px;
+
+  .memory-prompt-title {
+    font-size: 12px;
+    font-weight: bold;
+    margin-bottom: 10px;
+  }
+
+  .memory-prompt-content {
+    background-color: var(--gray);
+    border-radius: 6px;
+    padding: 10px;
+    font-size: 12px;
+    user-select: text;
+  }
+}

+ 159 - 28
app/components/chat.tsx

@@ -9,6 +9,8 @@ import CopyIcon from "../icons/copy.svg";
 import DownloadIcon from "../icons/download.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import BotIcon from "../icons/bot.svg";
+import AddIcon from "../icons/add.svg";
+import DeleteIcon from "../icons/delete.svg";
 
 import {
   Message,
@@ -16,6 +18,7 @@ import {
   useChatStore,
   ChatSession,
   BOT_HELLO,
+  ROLES,
 } from "../store";
 
 import {
@@ -33,8 +36,9 @@ import Locale from "../locales";
 
 import { IconButton } from "./button";
 import styles from "./home.module.scss";
+import chatStyle from "./chat.module.scss";
 
-import { showModal, showToast } from "./ui-lib";
+import { Modal, showModal, showToast } from "./ui-lib";
 
 const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
   loading: () => <LoadingIcon />,
@@ -94,26 +98,130 @@ function exportMessages(messages: Message[], topic: string) {
   });
 }
 
-function showMemoryPrompt(session: ChatSession) {
-  showModal({
-    title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
-    children: (
-      <div className="markdown-body">
-        <pre className={styles["export-content"]}>
-          {session.memoryPrompt || Locale.Memory.EmptyContent}
-        </pre>
+function PromptToast(props: {
+  showModal: boolean;
+  setShowModal: (_: boolean) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.context;
+
+  const addContextPrompt = (prompt: Message) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context.push(prompt);
+    });
+  };
+
+  const removeContextPrompt = (i: number) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context.splice(i, 1);
+    });
+  };
+
+  const updateContextPrompt = (i: number, prompt: Message) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context[i] = prompt;
+    });
+  };
+
+  return (
+    <div className={chatStyle["prompt-toast"]} key="prompt-toast">
+      <div
+        className={chatStyle["prompt-toast-inner"] + " clickable"}
+        role="button"
+        onClick={() => props.setShowModal(true)}
+      >
+        <BrainIcon />
+        <span className={chatStyle["prompt-toast-content"]}>
+          已设置 {context.length} 条前置上下文
+        </span>
       </div>
-    ),
-    actions: [
-      <IconButton
-        key="copy"
-        icon={<CopyIcon />}
-        bordered
-        text={Locale.Memory.Copy}
-        onClick={() => copyToClipboard(session.memoryPrompt)}
-      />,
-    ],
-  });
+      {props.showModal && (
+        <div className="modal-mask">
+          <Modal
+            title="编辑前置上下文"
+            onClose={() => props.setShowModal(false)}
+            actions={[
+              <IconButton
+                key="copy"
+                icon={<CopyIcon />}
+                bordered
+                text={Locale.Memory.Copy}
+                onClick={() => copyToClipboard(session.memoryPrompt)}
+              />,
+            ]}
+          >
+            <>
+              {" "}
+              <div className={chatStyle["context-prompt"]}>
+                {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"]}
+                      onChange={(e) =>
+                        updateContextPrompt(i, {
+                          ...c,
+                          content: e.target.value as any,
+                        })
+                      }
+                    ></input>
+                    <IconButton
+                      icon={<DeleteIcon />}
+                      className={chatStyle["context-delete-button"]}
+                      onClick={() => removeContextPrompt(i)}
+                    />
+                  </div>
+                ))}
+
+                <div className={chatStyle["context-prompt-row"]}>
+                  <IconButton
+                    icon={<AddIcon />}
+                    text="新增"
+                    bordered
+                    className={chatStyle["context-prompt-button"]}
+                    onClick={() =>
+                      addContextPrompt({
+                        role: "system",
+                        content: "",
+                        date: "",
+                      })
+                    }
+                  />
+                </div>
+              </div>
+              <div className={chatStyle["memory-prompt"]}>
+                <div className={chatStyle["memory-prompt-title"]}>
+                  {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
+                  {session.messages.length})
+                </div>
+                <div className={chatStyle["memory-prompt-content"]}>
+                  {session.memoryPrompt || Locale.Memory.EmptyContent}
+                </div>
+              </div>
+            </>
+          </Modal>
+        </div>
+      )}
+    </div>
+  );
 }
 
 function useSubmitHandler() {
@@ -172,9 +280,8 @@ function useScrollToBottom() {
   // auto scroll
   useLayoutEffect(() => {
     const dom = scrollRef.current;
-
     if (dom && autoScroll) {
-      dom.scrollTop = dom.scrollHeight;
+      setTimeout(() => (dom.scrollTop = dom.scrollHeight), 500);
     }
   });
 
@@ -243,8 +350,12 @@ export function Chat(props: {
       setPromptHints([]);
     } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
       // check if need to trigger auto completion
-      if (text.startsWith("/") && text.length > 1) {
-        onSearch(text.slice(1));
+      if (text.startsWith("/")) {
+        let searchText = text.slice(1);
+        if (searchText.length === 0) {
+          searchText = " ";
+        }
+        onSearch(searchText);
       }
     }
   };
@@ -299,8 +410,18 @@ export function Chat(props: {
 
   const config = useChatStore((state) => state.config);
 
+  const context: RenderMessage[] = session.context.slice();
+
+  if (
+    context.length === 0 &&
+    session.messages.at(0)?.content !== BOT_HELLO.content
+  ) {
+    context.push(BOT_HELLO);
+  }
+
   // preview messages
-  const messages = (session.messages as RenderMessage[])
+  const messages = context
+    .concat(session.messages as RenderMessage[])
     .concat(
       isLoading
         ? [
@@ -326,6 +447,8 @@ export function Chat(props: {
         : [],
     );
 
+  const [showPromptModal, setShowPromptModal] = useState(false);
+
   return (
     <div className={styles.chat} key={session.id}>
       <div className={styles["window-header"]}>
@@ -365,7 +488,7 @@ export function Chat(props: {
               bordered
               title={Locale.Chat.Actions.CompressedHistory}
               onClick={() => {
-                showMemoryPrompt(session);
+                setShowPromptModal(true);
               }}
             />
           </div>
@@ -380,6 +503,11 @@ export function Chat(props: {
             />
           </div>
         </div>
+
+        <PromptToast
+          showModal={showPromptModal}
+          setShowModal={setShowPromptModal}
+        />
       </div>
 
       <div className={styles["chat-body"]} ref={scrollRef}>
@@ -402,7 +530,10 @@ export function Chat(props: {
                     {Locale.Chat.Typing}
                   </div>
                 )}
-                <div className={styles["chat-message-item"]}>
+                <div
+                  className={styles["chat-message-item"]}
+                  onMouseOver={() => inputRef.current?.blur()}
+                >
                   {!isUser &&
                     !(message.preview || message.content.length === 0) && (
                       <div className={styles["chat-message-top-actions"]}>
@@ -467,7 +598,7 @@ export function Chat(props: {
             ref={inputRef}
             className={styles["chat-input"]}
             placeholder={Locale.Chat.Input(submitKey)}
-            rows={4}
+            rows={2}
             onInput={(e) => onInput(e.currentTarget.value)}
             value={userInput}
             onKeyDown={onInputKeyDown}

+ 1 - 0
app/components/home.module.scss

@@ -218,6 +218,7 @@
   flex: 1;
   overflow: auto;
   padding: 20px;
+  position: relative;
 }
 
 .chat-body-title {

+ 3 - 1
app/components/home.tsx

@@ -149,11 +149,12 @@ export function Home() {
                   setOpenSettings(true);
                   setShowSideBar(false);
                 }}
+                shadow
               />
             </div>
             <div className={styles["sidebar-action"]}>
               <a href={REPO_URL} target="_blank">
-                <IconButton icon={<GithubIcon />} />
+                <IconButton icon={<GithubIcon />} shadow />
               </a>
             </div>
           </div>
@@ -165,6 +166,7 @@ export function Home() {
                 createNewSession();
                 setShowSideBar(false);
               }}
+              shadow
             />
           </div>
         </div>

+ 2 - 1
app/components/window.scss

@@ -1,6 +1,7 @@
 .window-header {
   padding: 14px 20px;
   border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
+  position: relative;
 
   display: flex;
   justify-content: space-between;
@@ -32,4 +33,4 @@
 
 .window-action-button {
   margin-left: 10px;
-}
+}

+ 1 - 1
app/locales/cn.ts

@@ -138,7 +138,7 @@ const cn = {
       Topic:
         "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
       Summarize:
-        "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内",
+        "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内",
     },
     ConfirmClearAll: "确认清除所有聊天、设置数据?",
   },

+ 1 - 1
app/locales/en.ts

@@ -142,7 +142,7 @@ const en: LocaleType = {
       Topic:
         "Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
       Summarize:
-        "Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.",
+        "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
     },
     ConfirmClearAll: "Confirm to clear all chat and setting data?",
   },

+ 1 - 1
app/locales/es.ts

@@ -142,7 +142,7 @@ const es: LocaleType = {
       Topic:
         "Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.",
       Summarize:
-        "Resuma nuestra discusión brevemente en 50 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
+        "Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
     },
     ConfirmClearAll:
       "¿Confirmar para borrar todos los datos de chat y configuración?",

+ 1 - 1
app/locales/tw.ts

@@ -137,7 +137,7 @@ const tw: LocaleType = {
         "這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
       Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
       Summarize:
-        "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 50 字以內",
+        "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 200 字以內",
     },
     ConfirmClearAll: "確認清除所有對話、設定數據?",
   },

+ 27 - 10
app/store/app.ts

@@ -53,6 +53,8 @@ export interface ChatConfig {
 
 export type ModelConfig = ChatConfig["modelConfig"];
 
+export const ROLES: Message["role"][] = ["system", "user", "assistant"];
+
 const ENABLE_GPT4 = true;
 
 export const ALL_MODELS = [
@@ -151,6 +153,7 @@ export interface ChatSession {
   id: number;
   topic: string;
   memoryPrompt: string;
+  context: Message[];
   messages: Message[];
   stat: ChatStat;
   lastUpdate: string;
@@ -158,7 +161,7 @@ export interface ChatSession {
 }
 
 const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
-export const BOT_HELLO = {
+export const BOT_HELLO: Message = {
   role: "assistant",
   content: Locale.Store.BotHello,
   date: "",
@@ -171,6 +174,7 @@ function createEmptySession(): ChatSession {
     id: Date.now(),
     topic: DEFAULT_TOPIC,
     memoryPrompt: "",
+    context: [],
     messages: [],
     stat: {
       tokenCount: 0,
@@ -380,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
         const session = get().currentSession();
         const config = get().config;
         const n = session.messages.length;
-        const recentMessages = session.messages.slice(
-          Math.max(0, n - config.historyMessageCount),
-        );
 
-        const memoryPrompt = get().getMemoryPrompt();
+        const context = session.context.slice();
 
-        if (session.memoryPrompt) {
-          recentMessages.unshift(memoryPrompt);
+        if (session.memoryPrompt && session.memoryPrompt.length > 0) {
+          const memoryPrompt = get().getMemoryPrompt();
+          context.push(memoryPrompt);
         }
 
+        const recentMessages = context.concat(
+          session.messages.slice(Math.max(0, n - config.historyMessageCount)),
+        );
+
         return recentMessages;
       },
 
@@ -427,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
         let toBeSummarizedMsgs = session.messages.slice(
           session.lastSummarizeIndex,
         );
+
         const historyMsgLength = countMessages(toBeSummarizedMsgs);
 
-        if (historyMsgLength > 4000) {
+        if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
+          const n = toBeSummarizedMsgs.length;
           toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-            -config.historyMessageCount,
+            Math.max(0, n - config.historyMessageCount),
           );
         }
 
@@ -494,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
     }),
     {
       name: LOCAL_KEY,
-      version: 1,
+      version: 1.1,
+      migrate(persistedState, version) {
+        const state = persistedState as ChatStore;
+
+        if (version === 1) {
+          state.sessions.forEach((s) => (s.context = []));
+        }
+
+        return state;
+      },
     },
   ),
 );

+ 14 - 2
app/styles/globals.scss

@@ -117,7 +117,7 @@ body {
 
 select {
   border: var(--border-in-light);
-  padding: 8px 10px;
+  padding: 10px;
   border-radius: 10px;
   appearance: none;
   cursor: pointer;
@@ -188,7 +188,7 @@ input[type="text"] {
   appearance: none;
   border-radius: 10px;
   border: var(--border-in-light);
-  height: 32px;
+  height: 36px;
   box-sizing: border-box;
   background: var(--white);
   color: var(--black);
@@ -256,3 +256,15 @@ pre {
     }
   }
 }
+
+.clickable {
+  cursor: pointer;
+
+  div:not(.no-dark) > svg {
+    filter: invert(0.5);
+  }
+
+  &:hover {
+    filter: brightness(0.9);
+  }
+}