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>
This commit is contained in:
mashiro 2024-04-04 21:44:07 +08:00 committed by GitHub
parent 0748f0b7ce
commit baa981cda7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 131 additions and 72 deletions

View File

@ -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 @@
}
}
]
}
}

View File

@ -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",

View File

@ -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

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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));
}