diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index defa09d61..547501b48 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -328,6 +328,7 @@ onUnmounted(() => { for (const att of attachments.value) { revokeAttachment(att); } + clearCompositionEndTimer(); }); // --------------------------------------------------------------------------- @@ -422,7 +423,36 @@ function handleSteer(): void { emit('steer', payload); } +let isComposingText = false; +let compositionEndTimer: ReturnType | null = null; + +function clearCompositionEndTimer(): void { + if (compositionEndTimer !== null) { + clearTimeout(compositionEndTimer); + compositionEndTimer = null; + } +} + +function handleCompositionStart(): void { + clearCompositionEndTimer(); + isComposingText = true; +} + +function handleCompositionEnd(): void { + clearCompositionEndTimer(); + compositionEndTimer = setTimeout(() => { + compositionEndTimer = null; + isComposingText = false; + }, 0); +} + +function isComposingKeyEvent(e: KeyboardEvent): boolean { + return isComposingText || e.isComposing || e.keyCode === 229; +} + function handleKeydown(e: KeyboardEvent): void { + if (isComposingKeyEvent(e)) return; + // Close dropdowns on Escape if (e.key === 'Escape') { if (dropdownOpen.value) { @@ -729,6 +759,8 @@ function selectModel(modelId: string): void { :placeholder="placeholder" rows="1" @keydown="handleKeydown" + @compositionstart="handleCompositionStart" + @compositionend="handleCompositionEnd" @input="handleInput" /> diff --git a/apps/kimi-web/test/composer.test.ts b/apps/kimi-web/test/composer.test.ts new file mode 100644 index 000000000..c00176136 --- /dev/null +++ b/apps/kimi-web/test/composer.test.ts @@ -0,0 +1,65 @@ +import { mount } from '@vue/test-utils'; +import { createI18n } from 'vue-i18n'; +import { afterEach, describe, expect, it } from 'vitest'; +import Composer from '../src/components/Composer.vue'; + +function mountComposer() { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + composer: { + interrupt: 'Interrupt', + interruptTitle: 'Interrupt', + placeholder: 'Message Kimi', + send: 'Send', + }, + }, + }, + }); + + return mount(Composer, { + global: { + plugins: [i18n], + }, + }); +} + +function waitForCompositionEndTimer(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('Composer IME input', () => { + it('does not submit when Enter confirms active composition', async () => { + const wrapper = mountComposer(); + const textarea = wrapper.get('textarea'); + + await textarea.setValue('ni'); + await textarea.trigger('compositionstart'); + await textarea.trigger('keydown', { key: 'Enter', isComposing: true }); + + expect(wrapper.emitted('submit')).toBeUndefined(); + }); + + it('does not submit the Enter that immediately follows compositionend', async () => { + const wrapper = mountComposer(); + const textarea = wrapper.get('textarea'); + + await textarea.setValue('你好'); + await textarea.trigger('compositionstart'); + await textarea.trigger('compositionend'); + await textarea.trigger('keydown', { key: 'Enter', isComposing: false }); + + expect(wrapper.emitted('submit')).toBeUndefined(); + + await waitForCompositionEndTimer(); + await textarea.trigger('keydown', { key: 'Enter', isComposing: false }); + + expect(wrapper.emitted('submit')).toEqual([[{ text: '你好', attachments: [] }]]); + }); +});