Browse Source

Merge pull request #2118 from Yidadaa/bugfix-0624

feat: #2013 #628 switch model button & chat commands
Yifei Zhang 1 year ago
parent
commit
7ee062e1de
8 changed files with 129 additions and 16 deletions
  1. 43 0
      app/command.ts
  2. 56 7
      app/components/chat.tsx
  3. 2 5
      app/components/sidebar.tsx
  4. 0 0
      app/icons/robot.svg
  5. 2 2
      app/locales/ar.ts
  6. 9 1
      app/locales/cn.ts
  7. 9 1
      app/locales/en.ts
  8. 8 0
      app/store/chat.ts

+ 43 - 0
app/command.ts

@@ -1,4 +1,5 @@
 import { useSearchParams } from "react-router-dom";
+import Locale from "./locales";
 
 type Command = (param: string) => void;
 interface Commands {
@@ -26,3 +27,45 @@ export function useCommand(commands: Commands = {}) {
     setSearchParams(searchParams);
   }
 }
+
+interface ChatCommands {
+  new?: Command;
+  newm?: Command;
+  next?: Command;
+  prev?: Command;
+  clear?: Command;
+  del?: Command;
+}
+
+export const ChatCommandPrefix = ":";
+
+export function useChatCommand(commands: ChatCommands = {}) {
+  function extract(userInput: string) {
+    return (
+      userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
+    ) as keyof ChatCommands;
+  }
+
+  function search(userInput: string) {
+    const input = extract(userInput);
+    const desc = Locale.Chat.Commands;
+    return Object.keys(commands)
+      .filter((c) => c.startsWith(input))
+      .map((c) => ({
+        title: desc[c as keyof ChatCommands],
+        content: ChatCommandPrefix + c,
+      }));
+  }
+
+  function match(userInput: string) {
+    const command = extract(userInput);
+    const matched = typeof commands[command] === "function";
+
+    return {
+      matched,
+      invoke: () => matched && commands[command]!(userInput),
+    };
+  }
+
+  return { match, search };
+}

+ 56 - 7
app/components/chat.tsx

@@ -27,6 +27,7 @@ import DarkIcon from "../icons/dark.svg";
 import AutoIcon from "../icons/auto.svg";
 import BottomIcon from "../icons/bottom.svg";
 import StopIcon from "../icons/pause.svg";
+import RobotIcon from "../icons/robot.svg";
 
 import {
   ChatMessage,
@@ -38,6 +39,7 @@ import {
   Theme,
   useAppConfig,
   DEFAULT_TOPIC,
+  ALL_MODELS,
 } from "../store";
 
 import {
@@ -64,7 +66,7 @@ import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
 import { Avatar } from "./emoji";
 import { MaskAvatar, MaskConfig } from "./mask";
 import { useMaskStore } from "../store/mask";
-import { useCommand } from "../command";
+import { ChatCommandPrefix, useChatCommand, useCommand } from "../command";
 import { prettyObject } from "../utils/format";
 import { ExportMessageModal } from "./exporter";
 import { getClientConfig } from "../config/client";
@@ -206,8 +208,7 @@ export function PromptHints(props: {
 
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
-      if (noPrompts) return;
-      if (e.metaKey || e.altKey || e.ctrlKey) {
+      if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
         return;
       }
       // arrow up / down to select prompt
@@ -385,6 +386,19 @@ export function ChatActions(props: {
   const couldStop = ChatControllerPool.hasPending();
   const stopAll = () => ChatControllerPool.stopAll();
 
+  // switch model
+  const currentModel = chatStore.currentSession().mask.modelConfig.model;
+  function nextModel() {
+    const models = ALL_MODELS.filter((m) => m.available).map((m) => m.name);
+    const modelIndex = models.indexOf(currentModel);
+    const nextIndex = (modelIndex + 1) % models.length;
+    const nextModel = models[nextIndex];
+    chatStore.updateCurrentSession((session) => {
+      session.mask.modelConfig.model = nextModel;
+      session.mask.syncGlobalConfig = false;
+    });
+  }
+
   return (
     <div className={chatStyle["chat-input-actions"]}>
       {couldStop && (
@@ -453,6 +467,12 @@ export function ChatActions(props: {
           });
         }}
       />
+
+      <ChatAction
+        onClick={nextModel}
+        text={currentModel}
+        icon={<RobotIcon />}
+      />
     </div>
   );
 }
@@ -489,16 +509,19 @@ export function Chat() {
   const [promptHints, setPromptHints] = useState<Prompt[]>([]);
   const onSearch = useDebouncedCallback(
     (text: string) => {
-      setPromptHints(promptStore.search(text));
+      const matchedPrompts = promptStore.search(text);
+      setPromptHints(matchedPrompts);
     },
     100,
     { leading: true, trailing: true },
   );
 
   const onPromptSelect = (prompt: Prompt) => {
-    setPromptHints([]);
-    inputRef.current?.focus();
-    setTimeout(() => setUserInput(prompt.content), 60);
+    setTimeout(() => {
+      setPromptHints([]);
+      setUserInput(prompt.content);
+      inputRef.current?.focus();
+    }, 30);
   };
 
   // auto grow input
@@ -522,6 +545,19 @@ export function Chat() {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   useEffect(measure, [userInput]);
 
+  // chat commands shortcuts
+  const chatCommands = useChatCommand({
+    new: () => chatStore.newSession(),
+    newm: () => navigate(Path.NewChat),
+    prev: () => chatStore.nextSession(-1),
+    next: () => chatStore.nextSession(1),
+    clear: () =>
+      chatStore.updateCurrentSession(
+        (session) => (session.clearContextIndex = session.messages.length),
+      ),
+    del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
+  });
+
   // only search prompts when user input is short
   const SEARCH_TEXT_LIMIT = 30;
   const onInput = (text: string) => {
@@ -531,6 +567,8 @@ export function Chat() {
     // clear search results
     if (n === 0) {
       setPromptHints([]);
+    } else if (text.startsWith(ChatCommandPrefix)) {
+      setPromptHints(chatCommands.search(text));
     } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
       // check if need to trigger auto completion
       if (text.startsWith("/")) {
@@ -542,6 +580,13 @@ export function Chat() {
 
   const doSubmit = (userInput: string) => {
     if (userInput.trim() === "") return;
+    const matchCommand = chatCommands.match(userInput);
+    if (matchCommand.matched) {
+      setUserInput("");
+      setPromptHints([]);
+      matchCommand.invoke();
+      return;
+    }
     setIsLoading(true);
     chatStore.onUserInput(userInput).then(() => setIsLoading(false));
     localStorage.setItem(LAST_INPUT_KEY, userInput);
@@ -605,6 +650,10 @@ export function Chat() {
   const onRightClick = (e: any, message: ChatMessage) => {
     // copy to clipboard
     if (selectOrCopy(e.currentTarget, message.content)) {
+      if (userInput.length === 0) {
+        setUserInput(message.content);
+      }
+
       e.preventDefault();
     }
   };

+ 2 - 5
app/components/sidebar.tsx

@@ -38,13 +38,10 @@ function useHotKey() {
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
       if (e.metaKey || e.altKey || e.ctrlKey) {
-        const n = chatStore.sessions.length;
-        const limit = (x: number) => (x + n) % n;
-        const i = chatStore.currentSessionIndex;
         if (e.key === "ArrowUp") {
-          chatStore.selectSession(limit(i - 1));
+          chatStore.nextSession(-1);
         } else if (e.key === "ArrowDown") {
-          chatStore.selectSession(limit(i + 1));
+          chatStore.nextSession(1);
         }
       }
     };

File diff suppressed because it is too large
+ 0 - 0
app/icons/robot.svg


+ 2 - 2
app/locales/ar.ts

@@ -1,7 +1,7 @@
 import { SubmitKey } from "../store/config";
-import { LocaleType } from "./index";
+import type { PartialLocaleType } from "./index";
 
-const ar: LocaleType = {
+const ar: PartialLocaleType = {
   WIP: "قريبًا...",
   Error: {
     Unauthorized:

+ 9 - 1
app/locales/cn.ts

@@ -27,6 +27,14 @@ const cn = {
       Retry: "重试",
       Delete: "删除",
     },
+    Commands: {
+      new: "新建聊天",
+      newm: "从面具新建聊天",
+      next: "下一个聊天",
+      prev: "上一个聊天",
+      clear: "清除上下文",
+      del: "删除聊天",
+    },
     InputActions: {
       Stop: "停止响应",
       ToBottom: "滚到最新",
@@ -47,7 +55,7 @@ const cn = {
       if (submitKey === String(SubmitKey.Enter)) {
         inputHints += ",Shift + Enter 换行";
       }
-      return inputHints + ",/ 触发补全";
+      return inputHints + ",/ 触发补全,: 触发命令";
     },
     Send: "发送",
     Config: {

+ 9 - 1
app/locales/en.ts

@@ -28,6 +28,14 @@ const en: LocaleType = {
       Retry: "Retry",
       Delete: "Delete",
     },
+    Commands: {
+      new: "Start a new chat",
+      newm: "Start a new chat with mask",
+      next: "Next Chat",
+      prev: "Previous Chat",
+      clear: "Clear Context",
+      del: "Delete Chat",
+    },
     InputActions: {
       Stop: "Stop",
       ToBottom: "To Latest",
@@ -48,7 +56,7 @@ const en: LocaleType = {
       if (submitKey === String(SubmitKey.Enter)) {
         inputHints += ", Shift + Enter to wrap";
       }
-      return inputHints + ", / to search prompts";
+      return inputHints + ", / to search prompts, : to use commands";
     },
     Send: "Send",
     Config: {

+ 8 - 0
app/store/chat.ts

@@ -85,6 +85,7 @@ interface ChatStore {
   newSession: (mask?: Mask) => void;
   deleteSession: (index: number) => void;
   currentSession: () => ChatSession;
+  nextSession: (delta: number) => void;
   onNewMessage: (message: ChatMessage) => void;
   onUserInput: (content: string) => Promise<void>;
   summarizeSession: () => void;
@@ -200,6 +201,13 @@ export const useChatStore = create<ChatStore>()(
         }));
       },
 
+      nextSession(delta) {
+        const n = get().sessions.length;
+        const limit = (x: number) => (x + n) % n;
+        const i = get().currentSessionIndex;
+        get().selectSession(limit(i + delta));
+      },
+
       deleteSession(index) {
         const deletingLastSession = get().sessions.length === 1;
         const deletedSession = get().sessions.at(index);

Some files were not shown because too many files changed in this diff