mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
a201ef8c11
commit
5d63fc4d92
9 files changed
+155
-32
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user