diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index ea08540ae2..c966d13777 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -112,6 +112,10 @@ ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" + @compositionstart="handleCompositionStart" + @compositionend="handleCompositionEnd" + @compositioncancel="handleCompositionEnd" + @blur="clearCompositionState()" :disabled="disabled" placeholder="Ask AstrBot..." class="chat-textarea" @@ -307,6 +311,7 @@ import { import { useDisplay } from "vuetify"; import { useModuleI18n } from "@/i18n/composables"; import { useCustomizerStore } from "@/stores/customizer"; +import { isComposingEnter } from "@/utils/imeInput.mjs"; import ConfigSelector from "./ConfigSelector.vue"; import ProviderModelMenu from "./ProviderModelMenu.vue"; import StyledMenu from "@/components/shared/StyledMenu.vue"; @@ -379,6 +384,8 @@ const providerModelMenuRef = ref | null>( const showProviderSelector = ref(true); const isReplyClosing = ref(false); const isDragging = ref(false); +const isComposing = ref(false); +const lastCompositionEndAt = ref(null); let dragLeaveTimeout: number | null = null; const localPrompt = computed({ @@ -514,6 +521,10 @@ function handleKeyDown(e: KeyboardEvent) { return; } + if (isComposingEnter(e, isComposing.value, lastCompositionEndAt.value)) { + return; + } + const isSendHotkey = e.ctrlKey || e.metaKey || @@ -533,6 +544,23 @@ function handleKeyDown(e: KeyboardEvent) { } } +function handleCompositionStart() { + isComposing.value = true; + lastCompositionEndAt.value = null; +} + +function handleCompositionEnd(e: CompositionEvent) { + lastCompositionEndAt.value = e.timeStamp; + clearCompositionState({ keepLastEndAt: true }); +} + +function clearCompositionState({ keepLastEndAt = false } = {}) { + isComposing.value = false; + if (!keepLastEndAt) { + lastCompositionEndAt.value = null; + } +} + function handleKeyUp(e: KeyboardEvent) { if (e.keyCode === 66) { ctrlKeyDown.value = false; @@ -634,6 +662,7 @@ onBeforeUnmount(() => { if (inputField.value) { inputField.value.removeEventListener("paste", handlePaste); } + clearCompositionState(); document.removeEventListener("keyup", handleKeyUp); }); diff --git a/dashboard/src/utils/imeInput.mjs b/dashboard/src/utils/imeInput.mjs new file mode 100644 index 0000000000..3c24ffbae8 --- /dev/null +++ b/dashboard/src/utils/imeInput.mjs @@ -0,0 +1,31 @@ +// Some IMEs emit Enter right after compositionend; treat that same-keystroke +// window as composition so selecting a candidate does not send the message. +const RECENT_COMPOSITION_END_THRESHOLD_MS = 100; + +/** + * @param {KeyboardEvent} event + * @param {boolean} compositionActive + * @param {number | null} lastCompositionEndAt + */ +export function isComposingEnter( + event, + compositionActive, + lastCompositionEndAt = null, +) { + const hasLegacyCompositionKeyCode = + typeof event.keyCode === "number" && event.keyCode === 229; + const isAfterRecentCompositionEnd = + typeof event.timeStamp === "number" && + typeof lastCompositionEndAt === "number" && + event.timeStamp >= lastCompositionEndAt && + event.timeStamp - lastCompositionEndAt < + RECENT_COMPOSITION_END_THRESHOLD_MS; + + return ( + event.key === "Enter" && + (compositionActive || + event.isComposing || + hasLegacyCompositionKeyCode || + isAfterRecentCompositionEnd) + ); +} diff --git a/dashboard/tests/imeInput.test.mjs b/dashboard/tests/imeInput.test.mjs new file mode 100644 index 0000000000..1c5fea4731 --- /dev/null +++ b/dashboard/tests/imeInput.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isComposingEnter } from "../src/utils/imeInput.mjs"; + +test("detects Enter while an IME composition is active", () => { + assert.equal(isComposingEnter({ key: "Enter", isComposing: true }, false), true); + assert.equal(isComposingEnter({ key: "Enter", isComposing: false }, true), true); +}); + +test("does not treat normal Enter as IME composition", () => { + assert.equal(isComposingEnter({ key: "Enter", isComposing: false }, false), false); + assert.equal(isComposingEnter({ key: "a", isComposing: true }, true), false); +}); + +test("detects Enter fired immediately after composition ended", () => { + assert.equal( + isComposingEnter( + { key: "Enter", isComposing: false, timeStamp: 105 }, + false, + 100, + ), + true, + ); +}); + +test("does not treat delayed Enter after composition ended as IME composition", () => { + assert.equal( + isComposingEnter( + { key: "Enter", isComposing: false, timeStamp: 250 }, + false, + 100, + ), + false, + ); +});