From 5d63fc4d922d337fcab6e131be36d7a164626a85 Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 22 Aug 2026 19:22:37 +0800 Subject: [PATCH] fix(ai): stabilize conversation composer state Preserve unsent drafts until a message is accepted and while browsing global conversations. Keep CJK IME composition events flowing through the mention editor. --- src/components/AIChat/ChatExplorer.vue | 11 +++- .../AIChat/input/AIChatComposer.vue | 19 +++---- src/components/AIChat/input/AIChatInput.vue | 55 +++++++++++++++---- .../input/mention-menu-keyboard.test.ts | 19 +++++++ .../AIChat/input/mention-menu-keyboard.ts | 13 +++++ src/composables/useAIChat.ts | 5 +- src/pages/global-ai/index.vue | 15 ++++- src/stores/aiChat.ts | 14 ++++- src/stores/aiChatNavigation.test.ts | 36 +++++++++++- 9 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 src/components/AIChat/input/mention-menu-keyboard.test.ts create mode 100644 src/components/AIChat/input/mention-menu-keyboard.ts diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index a475ffda..99138c74 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -353,8 +353,15 @@ async function handleAssistantUpgradeConfirm(): Promise { } // 发送消息 -async function handleSend(payload: { content: string; mentionedMembers: MentionedMemberContext[] }) { - const result = await sendMessage(payload.content, { mentionedMembers: payload.mentionedMembers }) +async function handleSend(payload: { + content: string + mentionedMembers: MentionedMemberContext[] + onAccepted: () => void +}) { + const result = await sendMessage(payload.content, { + mentionedMembers: payload.mentionedMembers, + onAccepted: payload.onAccepted, + }) if (!result.success) { if (result.reason === 'busy') { showRunningTaskToast() diff --git a/src/components/AIChat/input/AIChatComposer.vue b/src/components/AIChat/input/AIChatComposer.vue index 29f390ac..ff348eec 100644 --- a/src/components/AIChat/input/AIChatComposer.vue +++ b/src/components/AIChat/input/AIChatComposer.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import UEditor from '@nuxt/ui/components/Editor.vue' import AIChatMentionMenu from './AIChatMentionMenu.vue' +import { resolveMentionMenuKeyboardAction } from './mention-menu-keyboard' interface EditorNode { type?: string @@ -198,19 +199,15 @@ function activateMentionMenuKeyboardSelection() { function handleMentionMenuKeydown(event: KeyboardEvent) { if (!mentionMenuHost?.firstElementChild) return - if (!hasMentionMenuKeyboardSelection && event.key === 'ArrowDown') { + const action = resolveMentionMenuKeyboardAction({ + key: event.key, + isComposing: event.isComposing, + hasKeyboardSelection: hasMentionMenuKeyboardSelection, + }) + if (action === 'activate' || action === 'activate-and-block') { activateMentionMenuKeyboardSelection() - event.preventDefault() - event.stopImmediatePropagation() - return } - - if (!hasMentionMenuKeyboardSelection && event.key === 'ArrowUp') { - activateMentionMenuKeyboardSelection() - return - } - - if (!hasMentionMenuKeyboardSelection && (event.key === 'Enter' || event.key === 'Tab')) { + if (action === 'block' || action === 'activate-and-block') { event.preventDefault() event.stopImmediatePropagation() } diff --git a/src/components/AIChat/input/AIChatInput.vue b/src/components/AIChat/input/AIChatInput.vue index 8b489939..205a3f0d 100644 --- a/src/components/AIChat/input/AIChatInput.vue +++ b/src/components/AIChat/input/AIChatInput.vue @@ -35,7 +35,14 @@ const props = withDefaults( ) const emit = defineEmits<{ - send: [payload: { content: string; mentionedMembers: MentionedMemberContext[]; entityRefs: AIEntityRef[] }] + send: [ + payload: { + content: string + mentionedMembers: MentionedMemberContext[] + entityRefs: AIEntityRef[] + onAccepted: () => void + }, + ] stop: [] manageSkills: [] skillActivated: [skill: SkillSummary] @@ -368,6 +375,18 @@ function fillInput(content: string) { nextTick(focusEditor) } +function clearDraft() { + inputValue.value = '' + selectedMentionIds.value = [] + mentionSearchTerm.value = '' + dismissedSlashValue.value = null + resetSlashState() + + if (props.skillsEnabled && activeSkillId.value) { + skillStore.activateSkill(null) + } +} + function handleMentionsChange(ids: string[]) { selectedMentionIds.value = ids } @@ -380,29 +399,40 @@ function handleMentionSearchTerm(value: string) { function handleSubmit() { if (!canSubmit.value) return - const candidates = selectedMentionIds.value.flatMap((id) => { + const content = inputValue.value.trim() + const submittedMentionIds = [...selectedMentionIds.value] + const candidates = submittedMentionIds.flatMap((id) => { const candidate = mentionRegistry.get(id) return candidate ? [candidate] : [] }) + const submittedSkillId = props.skillsEnabled ? activeSkillId.value : null + + const onAccepted = () => { + const mentionsUnchanged = + selectedMentionIds.value.length === submittedMentionIds.length && + selectedMentionIds.value.every((id, index) => id === submittedMentionIds[index]) + if (inputValue.value.trim() !== content || !mentionsUnchanged) return + + inputValue.value = '' + selectedMentionIds.value = [] + mentionSearchTerm.value = '' + dismissedSlashValue.value = null + + if (submittedSkillId && activeSkillId.value === submittedSkillId) { + skillStore.activateSkill(null) + } + } emit('send', { - content: inputValue.value.trim(), + content, mentionedMembers: candidates.flatMap((candidate) => candidate.mentionedMember ? [{ ...candidate.mentionedMember, aliases: [...candidate.mentionedMember.aliases] }] : [] ), entityRefs: candidates.flatMap((candidate) => (candidate.entityRef ? [{ ...candidate.entityRef }] : [])), + onAccepted, }) - - inputValue.value = '' - selectedMentionIds.value = [] - mentionSearchTerm.value = '' - dismissedSlashValue.value = null - - if (props.skillsEnabled && activeSkillId.value) { - skillStore.activateSkill(null) - } } function handleSelectSkill(skill: SkillSummary) { @@ -541,6 +571,7 @@ onBeforeUnmount(() => { }) defineExpose({ + clearDraft, fillInput, openSkillSelector, }) diff --git a/src/components/AIChat/input/mention-menu-keyboard.test.ts b/src/components/AIChat/input/mention-menu-keyboard.test.ts new file mode 100644 index 00000000..6498bee8 --- /dev/null +++ b/src/components/AIChat/input/mention-menu-keyboard.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolveMentionMenuKeyboardAction } from './mention-menu-keyboard' + +test('lets IME composition keys reach the editor while preserving mention keyboard navigation', () => { + const cases = [ + { key: 'Enter', isComposing: true, hasKeyboardSelection: false, expected: 'pass' }, + { key: 'ArrowDown', isComposing: true, hasKeyboardSelection: false, expected: 'pass' }, + { key: 'Enter', isComposing: false, hasKeyboardSelection: false, expected: 'block' }, + { key: 'Tab', isComposing: false, hasKeyboardSelection: false, expected: 'block' }, + { key: 'ArrowDown', isComposing: false, hasKeyboardSelection: false, expected: 'activate-and-block' }, + { key: 'ArrowUp', isComposing: false, hasKeyboardSelection: false, expected: 'activate' }, + { key: 'Enter', isComposing: false, hasKeyboardSelection: true, expected: 'pass' }, + ] as const + + for (const { expected, ...params } of cases) { + assert.equal(resolveMentionMenuKeyboardAction(params), expected, JSON.stringify(params)) + } +}) diff --git a/src/components/AIChat/input/mention-menu-keyboard.ts b/src/components/AIChat/input/mention-menu-keyboard.ts new file mode 100644 index 00000000..b2290dab --- /dev/null +++ b/src/components/AIChat/input/mention-menu-keyboard.ts @@ -0,0 +1,13 @@ +export type MentionMenuKeyboardAction = 'pass' | 'activate' | 'block' | 'activate-and-block' + +export function resolveMentionMenuKeyboardAction(params: { + key: string + isComposing: boolean + hasKeyboardSelection: boolean +}): MentionMenuKeyboardAction { + if (params.isComposing || params.hasKeyboardSelection) return 'pass' + if (params.key === 'ArrowDown') return 'activate-and-block' + if (params.key === 'ArrowUp') return 'activate' + if (params.key === 'Enter' || params.key === 'Tab') return 'block' + return 'pass' +} diff --git a/src/composables/useAIChat.ts b/src/composables/useAIChat.ts index 6593d4fc..688e5c08 100644 --- a/src/composables/useAIChat.ts +++ b/src/composables/useAIChat.ts @@ -13,6 +13,7 @@ import type { ToolBlockContent, MentionedMemberContext, ContentBlock, + SendMessageOptions, SendMessageResult, } from '@/stores/aiChat' import type { TokenUsage, AgentRuntimeStatus } from '@electron/shared/types' @@ -27,6 +28,7 @@ export type { ToolBlockContent, MentionedMemberContext, ContentBlock, + SendMessageOptions, SendMessageResult, } @@ -66,8 +68,7 @@ export function useAIChat( sessionTokenUsage: toRef(state, 'sessionTokenUsage'), agentStatus: toRef(state, 'agentStatus'), selectedAssistantId: toRef(state, 'selectedAssistantId'), - sendMessage: (content: string, options?: { mentionedMembers?: MentionedMemberContext[] }) => - aiChatStore.sendMessage(chatKey, content, options), + sendMessage: (content: string, options?: SendMessageOptions) => aiChatStore.sendMessage(chatKey, content, options), editMessageAndRegenerate: (messageId: string, content: string, options?: { overwriteSubsequent?: boolean }) => aiChatStore.editMessageAndRegenerate(chatKey, messageId, content, options), loadAIChat: (aiChatId: string) => aiChatStore.loadAIChat(chatKey, aiChatId), diff --git a/src/pages/global-ai/index.vue b/src/pages/global-ai/index.vue index b7ad5f01..8b71313f 100644 --- a/src/pages/global-ai/index.vue +++ b/src/pages/global-ai/index.vue @@ -34,6 +34,7 @@ const currentAIChatId = toRef(state, 'currentAIChatId') const conversations = ref([]) const conversationsLoading = ref(false) const initializing = ref(true) +const chatInputRef = ref<{ clearDraft: () => void } | null>(null) let isUnmounted = false let conversationSelectionRequestId = 0 @@ -97,6 +98,7 @@ async function selectConversation(aiChatId: string): Promise { function startNewConversation(): void { conversationSelectionRequestId++ if (!aiChatStore.startNewAIChat(chatKey)) return + chatInputRef.value?.clearDraft() initializing.value = false void syncAIChatIdToRoute(null) } @@ -121,8 +123,15 @@ async function deleteConversation(aiChatId: string): Promise { } } -async function handleSend(payload: { content: string; entityRefs: AIEntityRef[] }): Promise { - const result = await aiChatStore.sendMessage(chatKey, payload.content, { entityRefs: payload.entityRefs }) +async function handleSend(payload: { + content: string + entityRefs: AIEntityRef[] + onAccepted: () => void +}): Promise { + const result = await aiChatStore.sendMessage(chatKey, payload.content, { + entityRefs: payload.entityRefs, + onAccepted: payload.onAccepted, + }) if (result.success) { scrollToBottom(true) await loadConversations() @@ -338,7 +347,7 @@ onUnmounted(() => { class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5" > void +} + const DRAFT_AI_CHAT_KEY = '__draft__' function buildTimeFilterKey(timeFilter?: { startTs: number; endTs: number }): string { @@ -1012,7 +1019,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { async function sendMessage( chatKey: string, content: string, - options?: { mentionedMembers?: MentionedMemberContext[]; entityRefs?: AIEntityRef[] } + options?: SendMessageOptions ): Promise { const state = getSessionState(chatKey) if (!state) { @@ -1114,6 +1121,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { }) } targetBuffer.messages.push(aiMessage) + try { + options?.onAccepted?.() + } catch (error) { + console.error('[AI] Failed to acknowledge accepted user message:', error) + } let aiMessageIndex = targetBuffer.messages.length - 1 let hasStreamError = false const toolLifecycle = createToolLifecycleTracker() diff --git a/src/stores/aiChatNavigation.test.ts b/src/stores/aiChatNavigation.test.ts index f02f0ce0..dae25dd1 100644 --- a/src/stores/aiChatNavigation.test.ts +++ b/src/stores/aiChatNavigation.test.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia' import { ref } from 'vue' test('restores the latest valid AI chat without leaking stale navigation state', async (t) => { + let hasLLMConfig = false const makeConversation = (id: string, sessionId = 'session-one') => ({ id, sessionId, @@ -52,6 +53,9 @@ test('restores the latest valid AI chat without leaking stale navigation state', completionTokens: 1, totalTokens: 2, }), + createAIChat: async () => { + throw new Error('create failed') + }, } const assistantStore = { isLoaded: true, @@ -80,7 +84,7 @@ test('restores the latest valid AI chat without leaking stale navigation state', namedExports: { useAIService: () => aiService, useDataService: () => ({}), - useLLMService: () => ({}), + useLLMService: () => ({ hasConfig: async () => hasLLMConfig }), }, }), t.mock.module('@/services/ai-stream/service', { @@ -149,6 +153,36 @@ test('restores the latest valid AI chat without leaking stale navigation state', assert.equal(fourth.state.currentAIChatId, 'chat-user-choice') assert.equal(fourth.state.messages[0]?.content, 'Saved answer for chat-user-choice') + const fifth = store.ensureSessionState({ + sessionId: 'session-five', + sessionName: 'Fifth session', + chatType: 'private', + locale: 'zh-CN', + }) + store.selectAssistantForSession(fifth.chatKey, 'assistant-one') + let acceptedCount = 0 + const onAccepted = () => acceptedCount++ + + const noConfigResult = await store.sendMessage(fifth.chatKey, 'Keep this draft', { onAccepted }) + assert.equal(noConfigResult.reason, 'no_config') + assert.equal(acceptedCount, 0) + + fifth.state.isAIThinking = true + const busyResult = await store.sendMessage(fifth.chatKey, 'Keep this draft', { onAccepted }) + fifth.state.isAIThinking = false + assert.equal(busyResult.reason, 'busy') + assert.equal(acceptedCount, 0) + + hasLLMConfig = true + t.mock.method(console, 'error', () => undefined) + const acceptedResult = await store.sendMessage(fifth.chatKey, 'Accepted draft', { onAccepted }) + assert.equal(acceptedResult.reason, 'error') + assert.equal(acceptedCount, 1) + assert.equal( + fifth.state.messages.some((message) => message.role === 'user' && message.content === 'Accepted draft'), + true + ) + const global = store.ensureGlobalState('zh-CN') store.activeTask = { requestId: 'global-request',