From 260f62382b61074bc2776b96b8ddc32c283b7bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 27 Apr 2026 18:37:40 +0900 Subject: [PATCH 1/3] fix: prevent IME enter from sending chat --- dashboard/src/components/chat/ChatInput.vue | 16 ++++++++++++++++ dashboard/src/utils/imeInput.mjs | 6 ++++++ dashboard/tests/imeInput.test.mjs | 14 ++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 dashboard/src/utils/imeInput.mjs create mode 100644 dashboard/tests/imeInput.test.mjs diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index ea08540ae2..416e3521cf 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -112,6 +112,8 @@ ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" + @compositionstart="handleCompositionStart" + @compositionend="handleCompositionEnd" :disabled="disabled" placeholder="Ask AstrBot..." class="chat-textarea" @@ -307,6 +309,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 +382,7 @@ const providerModelMenuRef = ref | null>( const showProviderSelector = ref(true); const isReplyClosing = ref(false); const isDragging = ref(false); +const isComposing = ref(false); let dragLeaveTimeout: number | null = null; const localPrompt = computed({ @@ -514,6 +518,10 @@ function handleKeyDown(e: KeyboardEvent) { return; } + if (isComposingEnter(e, isComposing.value)) { + return; + } + const isSendHotkey = e.ctrlKey || e.metaKey || @@ -533,6 +541,14 @@ function handleKeyDown(e: KeyboardEvent) { } } +function handleCompositionStart() { + isComposing.value = true; +} + +function handleCompositionEnd() { + isComposing.value = false; +} + function handleKeyUp(e: KeyboardEvent) { if (e.keyCode === 66) { ctrlKeyDown.value = false; diff --git a/dashboard/src/utils/imeInput.mjs b/dashboard/src/utils/imeInput.mjs new file mode 100644 index 0000000000..96e7204cc8 --- /dev/null +++ b/dashboard/src/utils/imeInput.mjs @@ -0,0 +1,6 @@ +export function isComposingEnter(event, compositionActive) { + return ( + event.key === "Enter" && + (compositionActive || event.isComposing || event.keyCode === 229) + ); +} diff --git a/dashboard/tests/imeInput.test.mjs b/dashboard/tests/imeInput.test.mjs new file mode 100644 index 0000000000..8c519a838b --- /dev/null +++ b/dashboard/tests/imeInput.test.mjs @@ -0,0 +1,14 @@ +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); +}); From 88a4ccb314a31182970c73ea8b83da2920c82272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 27 Apr 2026 22:46:18 +0900 Subject: [PATCH 2/3] fix: prevent IME enter from sending chat --- dashboard/src/components/chat/ChatInput.vue | 19 ++++++++++++++-- dashboard/src/utils/imeInput.mjs | 24 +++++++++++++++++++-- dashboard/tests/imeInput.test.mjs | 22 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 416e3521cf..3c5202533f 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -114,6 +114,8 @@ @keydown="handleKeyDown" @compositionstart="handleCompositionStart" @compositionend="handleCompositionEnd" + @compositioncancel="handleCompositionEnd" + @blur="clearCompositionState" :disabled="disabled" placeholder="Ask AstrBot..." class="chat-textarea" @@ -383,6 +385,7 @@ 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({ @@ -518,7 +521,7 @@ function handleKeyDown(e: KeyboardEvent) { return; } - if (isComposingEnter(e, isComposing.value)) { + if (isComposingEnter(e, isComposing.value, lastCompositionEndAt.value)) { return; } @@ -543,10 +546,21 @@ function handleKeyDown(e: KeyboardEvent) { function handleCompositionStart() { isComposing.value = true; + lastCompositionEndAt.value = null; } -function handleCompositionEnd() { +function handleCompositionEnd(e: CompositionEvent) { + lastCompositionEndAt.value = e.timeStamp; + resetComposingState(); +} + +function resetComposingState() { + isComposing.value = false; +} + +function clearCompositionState() { isComposing.value = false; + lastCompositionEndAt.value = null; } function handleKeyUp(e: KeyboardEvent) { @@ -650,6 +664,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 index 96e7204cc8..6531691d14 100644 --- a/dashboard/src/utils/imeInput.mjs +++ b/dashboard/src/utils/imeInput.mjs @@ -1,6 +1,26 @@ -export function isComposingEnter(event, compositionActive) { +/** + * @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 < 100; + return ( event.key === "Enter" && - (compositionActive || event.isComposing || event.keyCode === 229) + (compositionActive || + event.isComposing || + hasLegacyCompositionKeyCode || + isAfterRecentCompositionEnd) ); } diff --git a/dashboard/tests/imeInput.test.mjs b/dashboard/tests/imeInput.test.mjs index 8c519a838b..1c5fea4731 100644 --- a/dashboard/tests/imeInput.test.mjs +++ b/dashboard/tests/imeInput.test.mjs @@ -12,3 +12,25 @@ 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, + ); +}); From 6bed7be6466d53d2faae840ccf010c4421a3f79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Mon, 27 Apr 2026 22:53:11 +0900 Subject: [PATCH 3/3] refactor: clarify IME composition state handling --- dashboard/src/components/chat/ChatInput.vue | 14 ++++++-------- dashboard/src/utils/imeInput.mjs | 7 ++++++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 3c5202533f..c966d13777 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -115,7 +115,7 @@ @compositionstart="handleCompositionStart" @compositionend="handleCompositionEnd" @compositioncancel="handleCompositionEnd" - @blur="clearCompositionState" + @blur="clearCompositionState()" :disabled="disabled" placeholder="Ask AstrBot..." class="chat-textarea" @@ -551,16 +551,14 @@ function handleCompositionStart() { function handleCompositionEnd(e: CompositionEvent) { lastCompositionEndAt.value = e.timeStamp; - resetComposingState(); + clearCompositionState({ keepLastEndAt: true }); } -function resetComposingState() { +function clearCompositionState({ keepLastEndAt = false } = {}) { isComposing.value = false; -} - -function clearCompositionState() { - isComposing.value = false; - lastCompositionEndAt.value = null; + if (!keepLastEndAt) { + lastCompositionEndAt.value = null; + } } function handleKeyUp(e: KeyboardEvent) { diff --git a/dashboard/src/utils/imeInput.mjs b/dashboard/src/utils/imeInput.mjs index 6531691d14..3c24ffbae8 100644 --- a/dashboard/src/utils/imeInput.mjs +++ b/dashboard/src/utils/imeInput.mjs @@ -1,3 +1,7 @@ +// 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 @@ -14,7 +18,8 @@ export function isComposingEnter( typeof event.timeStamp === "number" && typeof lastCompositionEndAt === "number" && event.timeStamp >= lastCompositionEndAt && - event.timeStamp - lastCompositionEndAt < 100; + event.timeStamp - lastCompositionEndAt < + RECENT_COMPOSITION_END_THRESHOLD_MS; return ( event.key === "Enter" &&