Explorar o código

fix: block input send event while ime composition (#701)

* fix: trigger send event while ime composition & separate input element & disable input event while initializing

* fix: eslint react plugin setting

---------

Co-authored-by: Jim Su <jimsu@protonmail.com>
mashiro hai 1 ano
pai
achega
baa981cda7

+ 9 - 2
frontend/.eslintrc

@@ -8,12 +8,19 @@
     "airbnb-typescript",
     "prettier",
     "plugin:@typescript-eslint/eslint-recommended",
-    "plugin:@typescript-eslint/recommended"
+    "plugin:@typescript-eslint/recommended",
+    "plugin:react/recommended",
+    "plugin:react-hooks/recommended"
   ],
   "plugins": ["prettier"],
   "rules": {
     "prettier/prettier": ["error"]
   },
+  "settings": {
+    "react": {
+      "version": "detect"
+    }
+  },
   "overrides": [
     {
       "files": ["*.ts", "*.tsx"],
@@ -43,4 +50,4 @@
       }
     }
   ]
-}
+}

+ 1 - 6
frontend/package.json

@@ -27,6 +27,7 @@
     "react-dom": "^18.2.0",
     "react-redux": "^9.1.0",
     "react-syntax-highlighter": "^15.5.0",
+    "tailwind-merge": "^2.2.2",
     "typescript": "^5.4.3",
     "vite": "^5.1.6",
     "vite-tsconfig-paths": "^4.3.2",
@@ -52,12 +53,6 @@
       "prettier --write"
     ]
   },
-  "eslintConfig": {
-    "extends": [
-      "react-app",
-      "react-app/jest"
-    ]
-  },
   "jest": {
     "preset": "ts-jest/presets/js-with-ts",
     "testEnvironment": "jest-environment-jsdom",

+ 3 - 0
frontend/pnpm-lock.yaml

@@ -65,6 +65,9 @@ dependencies:
   react-syntax-highlighter:
     specifier: ^15.5.0
     version: 15.5.0(react@18.2.0)
+  tailwind-merge:
+    specifier: ^2.2.2
+    version: 2.2.2
   typescript:
     specifier: ^5.4.3
     version: 5.4.3

+ 20 - 59
frontend/src/components/ChatInterface.tsx

@@ -1,19 +1,19 @@
-import { Card, CardBody, Textarea } from "@nextui-org/react";
-import React, { useEffect, useRef, useState } from "react";
+import { Card, CardBody } from "@nextui-org/react";
+import React, { useEffect, useRef } from "react";
 import { useSelector } from "react-redux";
 import assistantAvatar from "../assets/assistant-avatar.png";
 import CogTooth from "../assets/cog-tooth";
 import userAvatar from "../assets/user-avatar.png";
 import { useTypingEffect } from "../hooks/useTypingEffect";
 import {
-  sendChatMessage,
   setCurrentQueueMarkerState,
   setCurrentTypingMsgState,
   setTypingAcitve,
-  addAssistanctMessageToChat,
+  addAssistantMessageToChat,
 } from "../services/chatService";
 import { RootState } from "../store";
 import { Message } from "../state/chatSlice";
+import Input from "./Input";
 
 interface IChatBubbleProps {
   msg: Message;
@@ -31,25 +31,22 @@ function TypingChat() {
   const { currentTypingMessage, currentQueueMarker, queuedTyping, messages } =
     useSelector((state: RootState) => state.chat);
 
+  const messageContent = useTypingEffect([currentTypingMessage], {
+    loop: false,
+    setTypingAcitve,
+    setCurrentQueueMarkerState,
+    currentQueueMarker,
+    playbackRate: 0.1,
+    addAssistantMessageToChat,
+    assistantMessageObj: messages?.[queuedTyping[currentQueueMarker]],
+  });
+
   return (
-    // eslint-disable-next-line react/jsx-no-useless-fragment
-    <>
-      {currentQueueMarker !== null && (
-        <Card className="bg-success-100">
-          <CardBody>
-            {useTypingEffect([currentTypingMessage], {
-              loop: false,
-              setTypingAcitve,
-              setCurrentQueueMarkerState,
-              currentQueueMarker,
-              playbackRate: 0.1,
-              addAssistanctMessageToChat,
-              assistantMessageObj: messages?.[queuedTyping[currentQueueMarker]],
-            })}
-          </CardBody>
-        </Card>
-      )}
-    </>
+    currentQueueMarker !== null && (
+      <Card className="bg-success-100">
+        <CardBody>{messageContent}</CardBody>
+      </Card>
+    )
   );
 }
 
@@ -190,14 +187,6 @@ interface Props {
 
 function ChatInterface({ setSettingOpen }: Props): JSX.Element {
   const { initialized } = useSelector((state: RootState) => state.task);
-  const [inputMessage, setInputMessage] = useState("");
-
-  const handleSendMessage = () => {
-    if (inputMessage.trim() !== "") {
-      sendChatMessage(inputMessage);
-      setInputMessage("");
-    }
-  };
 
   return (
     <div className="flex flex-col h-full p-0 bg-bg-light">
@@ -211,35 +200,7 @@ function ChatInterface({ setSettingOpen }: Props): JSX.Element {
         </div>
       </div>
       {initialized ? <MessageList /> : <InitializingStatus />}
-      <div className="w-full relative text-base">
-        <Textarea
-          className="py-4 px-4"
-          classNames={{
-            input: "pr-16 py-2",
-          }}
-          value={inputMessage}
-          maxRows={10}
-          minRows={1}
-          variant="bordered"
-          onChange={(e) =>
-            e.target.value !== "\n" && setInputMessage(e.target.value)
-          }
-          placeholder="Send a message (won't interrupt the Assistant)"
-          onKeyDown={(e) => {
-            if (e.key === "Enter" && !e.shiftKey) {
-              handleSendMessage();
-            }
-          }}
-        />
-        <button
-          type="button"
-          className="bg-transparent border-none rounded py-2.5 px-5 hover:opacity-80 cursor-pointer select-none absolute right-5 bottom-6"
-          onClick={handleSendMessage}
-          disabled={!initialized}
-        >
-          Send
-        </button>
-      </div>
+      <Input />
     </div>
   );
 }

+ 74 - 0
frontend/src/components/Input.tsx

@@ -0,0 +1,74 @@
+import React, { ChangeEvent, useState, KeyboardEvent } from "react";
+import { useSelector } from "react-redux";
+import { Textarea } from "@nextui-org/react";
+import { twMerge } from "tailwind-merge";
+import { RootState } from "../store";
+import useInputComposition from "../hooks/useInputComposition";
+import { sendChatMessage } from "../services/chatService";
+
+function Input() {
+  const { initialized } = useSelector((state: RootState) => state.task);
+  const [inputMessage, setInputMessage] = useState("");
+
+  const handleSendMessage = () => {
+    if (inputMessage.trim() !== "") {
+      sendChatMessage(inputMessage);
+      setInputMessage("");
+    }
+  };
+
+  const { onCompositionEnd, onCompositionStart, isComposing } =
+    useInputComposition();
+
+  const handleChangeInputMessage = (e: ChangeEvent<HTMLInputElement>) => {
+    if (e.target.value !== "\n") {
+      setInputMessage(e.target.value);
+    }
+  };
+
+  const handleSendMessageOnEnter = (e: KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      // Prevent "Enter" from sending during IME input (e.g., Chinese, Japanese)
+      if (isComposing) {
+        return;
+      }
+      e.preventDefault();
+      e.stopPropagation();
+      handleSendMessage();
+    }
+  };
+
+  return (
+    <div className="w-full relative text-base">
+      <Textarea
+        disabled={!initialized}
+        className="py-4 px-4"
+        classNames={{
+          input: "pr-16 py-2",
+        }}
+        value={inputMessage}
+        maxRows={10}
+        minRows={1}
+        variant="bordered"
+        onChange={handleChangeInputMessage}
+        onKeyDown={handleSendMessageOnEnter}
+        onCompositionStart={onCompositionStart}
+        onCompositionEnd={onCompositionEnd}
+        placeholder="Send a message (won't interrupt the Assistant)"
+      />
+      <button
+        type="button"
+        className={twMerge(
+          "bg-transparent border-none rounded py-2.5 px-5 hover:opacity-80 cursor-pointer select-none absolute right-5 bottom-6",
+          !initialized && "cursor-not-allowed opacity-80",
+        )}
+        onClick={handleSendMessage}
+        disabled={!initialized}
+      >
+        Send
+      </button>
+    </div>
+  );
+}
+
+export default Input;

+ 19 - 0
frontend/src/hooks/useInputComposition.ts

@@ -0,0 +1,19 @@
+import { useState } from "react";
+
+const useInputComposition = () => {
+  const [isComposing, setIsComposing] = useState(false);
+  const handleCompositionStart = () => {
+    setIsComposing(true);
+  };
+  const handleCompositionEnd = () => {
+    setIsComposing(false);
+  };
+
+  return {
+    isComposing,
+    onCompositionStart: handleCompositionStart,
+    onCompositionEnd: handleCompositionEnd,
+  };
+};
+
+export default useInputComposition;

+ 4 - 4
frontend/src/hooks/useTypingEffect.ts

@@ -11,7 +11,7 @@ export const useTypingEffect = (
     setTypingAcitve = () => {},
     setCurrentQueueMarkerState = () => {},
     currentQueueMarker = 0,
-    addAssistanctMessageToChat = () => {},
+    addAssistantMessageToChat = () => {},
     assistantMessageObj = { content: "", sender: "assistant" },
   }: {
     loop?: boolean;
@@ -19,14 +19,14 @@ export const useTypingEffect = (
     setTypingAcitve?: (bool: boolean) => void;
     setCurrentQueueMarkerState?: (marker: number) => void;
     currentQueueMarker?: number;
-    addAssistanctMessageToChat?: (msg: Message) => void;
+    addAssistantMessageToChat?: (msg: Message) => void;
     assistantMessageObj?: Message;
   } = {
     loop: false,
     playbackRate: 0.1,
     setTypingAcitve: () => {},
     currentQueueMarker: 0,
-    addAssistanctMessageToChat: () => {},
+    addAssistantMessageToChat: () => {},
     assistantMessageObj: { content: "", sender: "assistant" },
   },
 ) => {
@@ -51,7 +51,7 @@ export const useTypingEffect = (
         if (!loop) {
           setTypingAcitve(false);
           setCurrentQueueMarkerState(currentQueueMarker + 1);
-          addAssistanctMessageToChat(assistantMessageObj);
+          addAssistantMessageToChat(assistantMessageObj);
           return;
         }
         stringIndex = 0;

+ 1 - 1
frontend/src/services/chatService.ts

@@ -31,6 +31,6 @@ export function setCurrentTypingMsgState(msg: string): void {
 export function setCurrentQueueMarkerState(index: number): void {
   store.dispatch(setCurrentQueueMarker(index));
 }
-export function addAssistanctMessageToChat(msg: Message): void {
+export function addAssistantMessageToChat(msg: Message): void {
   store.dispatch(appeendToNewChatSequence(msg));
 }