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.
This commit is contained in:
digua committed 2026-08-22 20:00:03 +08:00
1 parent a201ef8c11
commit 5d63fc4d92
9 files changed
+155 -32

No files matched your search

+9 -2
View File
@@ -353,8 +353,15 @@ async function handleAssistantUpgradeConfirm(): Promise<void> {
}
// 发送消息
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()
+8 -11
View File
@@ -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()
}
+43 -12
View File
@@ -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,
})
@@ -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))
}
})
@@ -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'
}
+3 -2
View File
@@ -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),
+12 -3
View File
@@ -34,6 +34,7 @@ const currentAIChatId = toRef(state, 'currentAIChatId')
const conversations = ref<AIChat[]>([])
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<void> {
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<void> {
}
}
async function handleSend(payload: { content: string; entityRefs: AIEntityRef[] }): Promise<void> {
const result = await aiChatStore.sendMessage(chatKey, payload.content, { entityRefs: payload.entityRefs })
async function handleSend(payload: {
content: string
entityRefs: AIEntityRef[]
onAccepted: () => void
}): Promise<void> {
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"
>
<AIChatInput
:key="currentAIChatId || 'global-draft'"
ref="chatInputRef"
embedded
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
+13 -1
View File
@@ -208,6 +208,13 @@ export interface SendMessageResult {
activeTask?: AIBackgroundTask | null
}
export interface SendMessageOptions {
mentionedMembers?: MentionedMemberContext[]
entityRefs?: AIEntityRef[]
/** Called once the user message has passed preflight checks and entered the visible conversation. */
onAccepted?: () => 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<SendMessageResult> {
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()
+35 -1
View File
@@ -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',