From 79e2511f6f881877806daea6766ab30164ed9480 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 14 Aug 2026 22:32:53 +0800 Subject: [PATCH] feat: improve application responsiveness --- src/main/agent-event-buffer.test.ts | 177 ++++ src/main/agent-event-buffer.ts | 125 +++ src/main/assistant/assistant-database.test.ts | 423 ++++++++++ src/main/assistant/assistant-database.ts | 325 ++++++-- src/main/ipc.test.ts | 272 ++++++- src/main/ipc.ts | 247 ++++-- src/preload/index.ts | 36 + src/renderer/src/App.test.tsx | 751 +++++++++++++++-- src/renderer/src/App.tsx | 753 +++++++++++++++--- src/renderer/src/activity-store.test.ts | 18 + src/renderer/src/activity-store.ts | 26 +- src/renderer/src/i18n/locales/en-US/app.ts | 8 + src/renderer/src/i18n/locales/zh-CN/app.ts | 8 + src/renderer/src/styles.css | 73 ++ src/shared/assistant-contracts.ts | 181 +++-- src/shared/contracts.ts | 4 + src/shared/ipc-channels.ts | 5 + tests/performance-regression.manual.test.ts | 191 +++++ 18 files changed, 3268 insertions(+), 355 deletions(-) create mode 100644 src/main/agent-event-buffer.test.ts create mode 100644 src/main/agent-event-buffer.ts create mode 100644 tests/performance-regression.manual.test.ts diff --git a/src/main/agent-event-buffer.test.ts b/src/main/agent-event-buffer.test.ts new file mode 100644 index 0000000..9197067 --- /dev/null +++ b/src/main/agent-event-buffer.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentEvent } from '../shared/contracts' +import { AgentEventBuffer } from './agent-event-buffer' + +const requestId = '00000000-0000-4000-8000-000000000001' + +describe('AgentEventBuffer', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('combines 100 adjacent text deltas into one event', () => { + vi.useFakeTimers() + const events: AgentEvent[] = [] + const buffer = new AgentEventBuffer({ + onEvent: (event) => events.push(event) + }) + + for (let index = 0; index < 100; index += 1) { + buffer.push({ requestId, type: 'text', delta: `${index},` }) + } + expect(events).toEqual([]) + + buffer.close() + expect(events).toEqual([ + { + requestId, + type: 'text', + delta: Array.from({ length: 100 }, (_, index) => `${index},`).join( + '' + ) + } + ]) + }) + + it('preserves text, reasoning, and immediate tool order', () => { + vi.useFakeTimers() + const events: AgentEvent[] = [] + const buffer = new AgentEventBuffer({ + onEvent: (event) => events.push(event) + }) + + buffer.push({ requestId, type: 'text', delta: 'answer' }) + buffer.push({ requestId, type: 'reasoning', delta: 'thought' }) + buffer.push({ + requestId, + type: 'tool', + callId: 'call-1', + name: 'read', + state: 'completed', + summary: 'read completed' + }) + + expect(events.map((event) => event.type)).toEqual([ + 'text', + 'reasoning', + 'tool' + ]) + }) + + it('flushes before a combined delta exceeds the size bound', () => { + vi.useFakeTimers() + const events: AgentEvent[] = [] + const buffer = new AgentEventBuffer({ + maximumBufferedBytes: 5, + onEvent: (event) => events.push(event) + }) + + buffer.push({ requestId, type: 'text', delta: '123' }) + buffer.push({ requestId, type: 'text', delta: '456' }) + expect(events).toEqual([ + { requestId, type: 'text', delta: '123' } + ]) + + buffer.close() + expect(events).toEqual([ + { requestId, type: 'text', delta: '123' }, + { requestId, type: 'text', delta: '456' } + ]) + }) + + it('flushes buffered deltas when its timer expires', () => { + vi.useFakeTimers() + const events: AgentEvent[] = [] + const buffer = new AgentEventBuffer({ + flushIntervalMs: 32, + onEvent: (event) => events.push(event) + }) + + buffer.push({ requestId, type: 'reasoning', delta: 'thinking' }) + vi.advanceTimersByTime(31) + expect(events).toEqual([]) + vi.advanceTimersByTime(1) + expect(events).toEqual([ + { requestId, type: 'reasoning', delta: 'thinking' } + ]) + }) + + it('supports frame-paced UI updates with coarser durable writes', () => { + vi.useFakeTimers() + const publicEvents: AgentEvent[] = [] + const persistedEvents: AgentEvent[] = [] + const publicBuffer = new AgentEventBuffer({ + flushIntervalMs: 16, + onEvent: (event) => publicEvents.push(event) + }) + const persistedBuffer = new AgentEventBuffer({ + flushIntervalMs: 32, + onEvent: (event) => persistedEvents.push(event) + }) + const first: AgentEvent = { + requestId, + type: 'text', + delta: 'first' + } + publicBuffer.push(first) + persistedBuffer.push(first) + + vi.advanceTimersByTime(16) + expect(publicEvents).toEqual([first]) + expect(persistedEvents).toEqual([]) + + const second: AgentEvent = { + requestId, + type: 'text', + delta: 'second' + } + publicBuffer.push(second) + persistedBuffer.push(second) + publicBuffer.close() + persistedBuffer.close() + + expect(publicEvents).toEqual([first, second]) + expect(persistedEvents).toEqual([ + { + requestId, + type: 'text', + delta: 'firstsecond' + } + ]) + }) + + it('closes idempotently and ignores events after close', () => { + vi.useFakeTimers() + const events: AgentEvent[] = [] + const buffer = new AgentEventBuffer({ + onEvent: (event) => events.push(event) + }) + + buffer.push({ requestId, type: 'text', delta: 'once' }) + buffer.close() + buffer.close() + buffer.push({ requestId, type: 'text', delta: 'late' }) + vi.runAllTimers() + + expect(events).toEqual([ + { requestId, type: 'text', delta: 'once' } + ]) + }) + + it('reports timer publication errors instead of throwing asynchronously', () => { + vi.useFakeTimers() + const error = new Error('database unavailable') + const onError = vi.fn() + const buffer = new AgentEventBuffer({ + flushIntervalMs: 32, + onError, + onEvent: () => { + throw error + } + }) + + buffer.push({ requestId, type: 'text', delta: 'pending' }) + expect(() => vi.advanceTimersByTime(32)).not.toThrow() + expect(onError).toHaveBeenCalledWith(error) + }) +}) diff --git a/src/main/agent-event-buffer.ts b/src/main/agent-event-buffer.ts new file mode 100644 index 0000000..c40900f --- /dev/null +++ b/src/main/agent-event-buffer.ts @@ -0,0 +1,125 @@ +import type { AgentEvent } from '../shared/contracts' + +type BufferedAgentEvent = Extract< + AgentEvent, + { type: 'text' | 'reasoning' } +> + +export type AgentEventBufferOptions = { + onEvent(event: AgentEvent): void + onError?(error: unknown): void + flushIntervalMs?: number + maximumBufferedBytes?: number +} + +const DEFAULT_FLUSH_INTERVAL_MS = 32 +const DEFAULT_MAXIMUM_BUFFERED_BYTES = 64 * 1024 + +export class AgentEventBuffer { + private readonly onEvent: (event: AgentEvent) => void + private readonly onError: ((error: unknown) => void) | undefined + private readonly flushIntervalMs: number + private readonly maximumBufferedBytes: number + private pending: BufferedAgentEvent | undefined + private pendingBytes = 0 + private timer: ReturnType | undefined + private closed = false + + constructor(options: AgentEventBufferOptions) { + this.onEvent = options.onEvent + this.onError = options.onError + this.flushIntervalMs = + options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS + this.maximumBufferedBytes = + options.maximumBufferedBytes ?? + DEFAULT_MAXIMUM_BUFFERED_BYTES + if (this.flushIntervalMs <= 0) { + throw new Error('flushIntervalMs must be positive') + } + if (this.maximumBufferedBytes <= 0) { + throw new Error('maximumBufferedBytes must be positive') + } + } + + push(event: AgentEvent): void { + if (this.closed) { + return + } + if (event.type !== 'text' && event.type !== 'reasoning') { + this.flush() + this.onEvent(event) + return + } + + const eventBytes = Buffer.byteLength(event.delta) + const matchesPending = + this.pending?.requestId === event.requestId && + this.pending.type === event.type + if (!matchesPending) { + this.flush() + } else if ( + this.pendingBytes + eventBytes > + this.maximumBufferedBytes + ) { + this.flush() + } + + if (eventBytes > this.maximumBufferedBytes) { + this.onEvent(event) + return + } + if (this.pending) { + this.pending = { + ...this.pending, + delta: this.pending.delta + event.delta + } + this.pendingBytes += eventBytes + return + } + + this.pending = { ...event } + this.pendingBytes = eventBytes + this.scheduleFlush() + } + + flush(): void { + this.clearTimer() + const event = this.pending + this.pending = undefined + this.pendingBytes = 0 + if (event) { + this.onEvent(event) + } + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + this.flush() + } + + private scheduleFlush(): void { + if (this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = undefined + try { + this.flush() + } catch (error) { + this.onError?.(error) + } + }, this.flushIntervalMs) + this.timer.unref?.() + } + + private clearTimer(): void { + if (!this.timer) { + return + } + clearTimeout(this.timer) + this.timer = undefined + } +} diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 9b54bfd..68208bf 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1386,6 +1386,429 @@ describe('AssistantDatabase', () => { database.close() }) + it('incrementally saves local changes without replacing unrelated or remote data', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-incremental-conversations-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const conversationId = + '00000000-0000-4000-8000-000000000501' + const unrelatedId = + '00000000-0000-4000-8000-000000000502' + const streamingMessageId = + '00000000-0000-4000-8000-000000000503' + const newMessageId = + '00000000-0000-4000-8000-000000000504' + database.replaceConversations([ + { + id: conversationId, + projectId: project.id, + title: '增量对话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: streamingMessageId, + role: 'assistant', + content: '生成中', + createdAt: 1_775_000_000_001, + state: 'streaming', + status: '正在生成' + } + ] + }, + { + id: unrelatedId, + title: '不相关本地对话', + updatedAt: 1_775_000_000_002, + messages: [] + } + ]) + const channelProject = database.ensureChannelProjects( + 'C:\\Users\\test', + channelDefaultProfileId + )[0]! + const remote = database.getOrCreateRemoteConversation({ + projectId: channelProject.id, + channel: 'weixin', + accountId: 'default', + externalConversationId: 'incremental-preserved', + conversationType: 'direct', + title: '保留的远程对话', + accountDisplay: '发送者 ****0501' + }) + const raw = new DatabaseSync(databasePath) + raw + .prepare('UPDATE messages SET request_id = ? WHERE id = ?') + .run('preserved-request-id', streamingMessageId) + raw.close() + + const save = [ + { + header: { + id: conversationId, + projectId: project.id, + title: '增量对话(已完成)', + updatedAt: 1_775_000_001_000 + }, + messages: [ + { + id: streamingMessageId, + role: 'assistant' as const, + content: '生成完成', + createdAt: 1_775_000_000_001, + state: 'complete' as const, + status: '已完成' + }, + { + id: newMessageId, + role: 'user' as const, + content: '继续', + createdAt: 1_775_000_001_000, + state: 'complete' as const + } + ] + } + ] + database.saveLocalConversations(save) + database.saveLocalConversations(save) + + expect(database.getConversation(conversationId)).toMatchObject({ + title: '增量对话(已完成)', + messages: [ + { + id: streamingMessageId, + content: '生成完成', + state: 'complete', + status: '已完成' + }, + { + id: newMessageId, + content: '继续', + state: 'complete' + } + ] + }) + expect(database.getConversation(unrelatedId).title).toBe( + '不相关本地对话' + ) + expect(database.getConversation(remote.id).remote?.channel).toBe( + 'weixin' + ) + + const durable = new DatabaseSync(databasePath) + expect( + durable + .prepare( + `SELECT id, sequence, request_id + FROM messages + WHERE conversation_id = ? + ORDER BY sequence` + ) + .all(conversationId) + ).toEqual([ + { + id: streamingMessageId, + sequence: 0, + request_id: 'preserved-request-id' + }, + { + id: newMessageId, + sequence: 1, + request_id: null + } + ]) + durable.close() + database.close() + }) + + it('keeps incremental local conversation storage bounded to 500 messages', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-bounded-local-conversation-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const conversationId = + '00000000-0000-4000-8000-000000000521' + const messageId = (index: number): string => + `00000000-0000-4000-8001-${String(index).padStart(12, '0')}` + database.replaceConversations([ + { + id: conversationId, + projectId: project.id, + title: '有界增量对话', + updatedAt: 1_775_000_000_000, + messages: Array.from({ length: 500 }, (_, index) => ({ + id: messageId(index), + role: index % 2 === 0 ? 'user' as const : 'assistant' as const, + content: `消息 ${index}`, + createdAt: 1_775_000_000_000 + index, + state: 'complete' as const + })) + } + ]) + + const newestMessageId = messageId(500) + database.saveLocalConversations([ + { + header: { + id: conversationId, + projectId: project.id, + title: '有界增量对话', + updatedAt: 1_775_000_001_000 + }, + messages: [ + { + id: newestMessageId, + role: 'user', + content: '最新消息', + createdAt: 1_775_000_001_000, + state: 'complete' + } + ] + } + ]) + + const restored = database.getConversation(conversationId) + expect(restored.messages).toHaveLength(500) + expect(restored.messages[0]?.id).toBe(messageId(1)) + expect(restored.messages.at(-1)?.id).toBe(newestMessageId) + const raw = new DatabaseSync(databasePath) + expect( + raw + .prepare( + `SELECT COUNT(*) AS count, MIN(sequence) AS minimum, + MAX(sequence) AS maximum + FROM messages + WHERE conversation_id = ?` + ) + .get(conversationId) + ).toEqual({ + count: 500, + minimum: 1, + maximum: 500 + }) + raw.close() + database.close() + }) + + it('rolls back incremental saves when message ownership or role changes', async () => { + const database = await createDatabase() + const firstConversationId = + '00000000-0000-4000-8000-000000000511' + const secondConversationId = + '00000000-0000-4000-8000-000000000512' + const firstMessageId = + '00000000-0000-4000-8000-000000000513' + const secondMessageId = + '00000000-0000-4000-8000-000000000514' + const rolledBackMessageId = + '00000000-0000-4000-8000-000000000515' + database.replaceConversations([ + { + id: firstConversationId, + title: '第一对话', + updatedAt: 1, + messages: [ + { + id: firstMessageId, + role: 'user', + content: '第一条', + createdAt: 1, + state: 'complete' + } + ] + }, + { + id: secondConversationId, + title: '第二对话', + updatedAt: 2, + messages: [ + { + id: secondMessageId, + role: 'assistant', + content: '第二条', + createdAt: 2, + state: 'complete' + } + ] + } + ]) + + expect(() => + database.saveLocalConversations([ + { + header: { + id: firstConversationId, + title: '不应提交的标题', + updatedAt: 3 + }, + messages: [ + { + id: rolledBackMessageId, + role: 'user', + content: '不应提交', + createdAt: 3, + state: 'complete' + }, + { + id: secondMessageId, + role: 'assistant', + content: '错误归属', + createdAt: 2, + state: 'complete' + } + ] + } + ]) + ).toThrow('消息 ID 已属于其他对话') + expect(database.getConversation(firstConversationId)).toMatchObject({ + title: '第一对话', + messages: [{ id: firstMessageId }] + }) + + expect(() => + database.saveLocalConversations([ + { + header: { + id: firstConversationId, + title: '仍不应提交的标题', + updatedAt: 4 + }, + messages: [ + { + id: firstMessageId, + role: 'assistant', + content: '错误角色', + createdAt: 1, + state: 'complete' + } + ] + } + ]) + ).toThrow('消息角色不能更改') + expect(database.getConversation(firstConversationId)).toMatchObject({ + title: '第一对话', + messages: [ + { + id: firstMessageId, + role: 'user', + content: '第一条' + } + ] + }) + database.close() + }) + + it('explicitly deletes only local conversations and cascades messages', async () => { + const database = await createDatabase() + const localId = '00000000-0000-4000-8000-000000000521' + database.replaceConversations([ + { + id: localId, + title: '待删除本地对话', + updatedAt: 1, + messages: [ + { + id: '00000000-0000-4000-8000-000000000522', + role: 'user', + content: '待删除消息', + createdAt: 1, + state: 'complete' + } + ] + } + ]) + const channelProject = database.ensureChannelProjects( + 'C:\\Users\\test', + channelDefaultProfileId + )[0]! + const remote = database.getOrCreateRemoteConversation({ + projectId: channelProject.id, + channel: 'weixin', + accountId: 'default', + externalConversationId: 'protected-delete', + conversationType: 'direct', + title: '受保护远程对话', + accountDisplay: '发送者 ****0521' + }) + database.appendRemoteConversationMessage({ + conversationId: remote.id, + role: 'user', + content: '远程消息' + }) + + expect(database.deleteLocalConversation(localId)).toBe(true) + expect(database.deleteLocalConversation(localId)).toBe(false) + expect(() => + database.getConversation(localId) + ).toThrow('对话不存在') + expect(() => + database.deleteLocalConversation(remote.id) + ).toThrow('远程对话不能作为本地对话删除') + expect(() => + database.saveLocalConversations([ + { + header: { + id: remote.id, + title: '冲突本地标题', + updatedAt: 2 + }, + messages: [] + } + ]) + ).toThrow('本地对话 ID 与远程对话冲突') + expect(database.getConversation(remote.id)).toMatchObject({ + title: '受保护远程对话', + messages: [{ content: '远程消息' }] + }) + database.close() + }) + + it('gets a targeted conversation outside the latest 100', async () => { + const database = await createDatabase() + database.saveLocalConversations( + Array.from({ length: 100 }, (_, index) => ({ + header: { + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + title: `较新对话 ${index}`, + updatedAt: index + 2 + }, + messages: [] + })) + ) + const oldestId = '00000000-0000-4000-8000-000000000999' + database.saveLocalConversations([ + { + header: { + id: oldestId, + title: '第 101 个对话', + updatedAt: 1 + }, + messages: [] + } + ]) + + expect(database.listConversations()).toHaveLength(100) + expect( + database.listConversations().some( + (conversation) => conversation.id === oldestId + ) + ).toBe(false) + expect(database.getConversation(oldestId)).toMatchObject({ + id: oldestId, + title: '第 101 个对话', + messages: [] + }) + database.close() + }) + it('repairs unattended channel selections without rebinding ordinary conversations', async () => { const database = await createDatabase() const removedProfileId = diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 11221e3..b80cc75 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -14,6 +14,7 @@ import type { AssistantProject, AssistantSchedule, AssistantTask, + ConversationMessage, ConversationSnapshot, ExpertCreateInput, ExpertUpdateInput, @@ -21,6 +22,7 @@ import type { HeartbeatSummaryOutput, HeartbeatUpdateInput, LegacyWorkMode, + LocalConversationSaveBatch, MemoryCreateInput, ModelUsageCallInput, ProjectChannel, @@ -749,6 +751,80 @@ function interruptActiveToolBlocks( ) } +function toConversationSnapshot( + conversation: ConversationRow, + messages: MessageRow[] +): ConversationSnapshot { + return { + id: conversation.id, + projectId: conversation.project_id ?? undefined, + runtimeSelection: parseRuntimeSelection( + conversation.runtime_selection_json + ), + knowledgeRetrievalMode: + conversation.knowledge_retrieval_mode ?? undefined, + ...(conversation.channel && + conversation.conversation_type && + conversation.account_display + ? { + remote: { + channel: conversation.channel, + accountDisplay: conversation.account_display, + conversationType: conversation.conversation_type + } + } + : {}), + title: conversation.title, + updatedAt: Date.parse(conversation.updated_at), + messages: messages.map((message) => { + const metadata = JSON.parse( + message.metadata_json + ) as MessageMetadata + const interrupted = message.state === 'streaming' + return { + id: message.id, + role: message.role, + content: message.content, + reasoning: metadata.reasoning, + blocks: interrupted + ? interruptActiveToolBlocks(metadata.blocks) + : metadata.blocks, + createdAt: + metadata.createdAt ?? Date.parse(message.created_at), + state: interrupted ? ('error' as const) : message.state, + status: interrupted + ? interruptedMessageStatus + : metadata.status, + tools: interrupted + ? interruptActiveTools(metadata.tools) + : metadata.tools, + sources: metadata.sources, + sourceReferences: metadata.sourceReferences, + knowledgeRetrieval: metadata.knowledgeRetrieval, + artifactIds: metadata.artifactIds, + attachments: metadata.attachments + } + }) + } +} + +function serializeConversationMessageMetadata( + message: ConversationMessage +): string { + return JSON.stringify({ + createdAt: message.createdAt, + status: message.status, + reasoning: message.reasoning, + blocks: message.blocks, + tools: message.tools, + sources: message.sources, + sourceReferences: message.sourceReferences, + knowledgeRetrieval: message.knowledgeRetrieval, + artifactIds: message.artifactIds, + attachments: message.attachments + }) +} + export class AssistantDatabase { private database?: DatabaseSync private channelEventWrites = 0 @@ -1279,69 +1355,45 @@ export class AssistantDatabase { ) ORDER BY sequence ASC` ) - return conversations.map((conversation) => ({ - id: conversation.id, - projectId: conversation.project_id ?? undefined, - runtimeSelection: parseRuntimeSelection( - conversation.runtime_selection_json - ), - knowledgeRetrievalMode: - conversation.knowledge_retrieval_mode ?? undefined, - ...(conversation.channel && - conversation.conversation_type && - conversation.account_display - ? { - remote: { - channel: conversation.channel, - accountDisplay: conversation.account_display, - conversationType: conversation.conversation_type - } - } - : {}), - title: conversation.title, - updatedAt: Date.parse(conversation.updated_at), - messages: ( + return conversations.map((conversation) => + toConversationSnapshot( + conversation, messageStatement.all(conversation.id) as MessageRow[] - ).map((message) => { - const metadata = JSON.parse( - message.metadata_json - ) as MessageMetadata - const interrupted = message.state === 'streaming' - return { - id: message.id, - role: message.role, - content: message.content, - reasoning: metadata.reasoning, - blocks: interrupted - ? interruptActiveToolBlocks(metadata.blocks) - : metadata.blocks, - createdAt: - metadata.createdAt ?? Date.parse(message.created_at), - state: interrupted ? ('error' as const) : message.state, - status: interrupted - ? interruptedMessageStatus - : metadata.status, - tools: interrupted - ? interruptActiveTools(metadata.tools) - : metadata.tools, - sources: metadata.sources, - sourceReferences: metadata.sourceReferences, - knowledgeRetrieval: metadata.knowledgeRetrieval, - artifactIds: metadata.artifactIds, - attachments: metadata.attachments - } - }) - })) + ) + ) } getConversation(conversationId: string): ConversationSnapshot { - const conversation = this.listConversations().find( - (candidate) => candidate.id === conversationId - ) + const database = this.requireDatabase() + const conversation = database + .prepare( + `SELECT id, project_id, runtime_selection_json, + knowledge_retrieval_mode, title, channel, + external_account_id, external_conversation_id, + conversation_type, account_display, updated_at + FROM conversations + WHERE id = ? AND status = 'active'` + ) + .get(conversationId) as ConversationRow | undefined if (!conversation) { throw new Error('对话不存在') } - return conversation + const messages = database + .prepare( + `SELECT id, conversation_id, role, content, state, metadata_json, + created_at + FROM ( + SELECT id, conversation_id, role, content, state, + metadata_json, created_at, sequence + FROM messages + WHERE conversation_id = ? + ORDER BY sequence DESC + LIMIT 500 + ) + ORDER BY sequence ASC` + ) + .all(conversationId) as MessageRow[] + return toConversationSnapshot(conversation, messages) } repairConversationRuntimeSelections( @@ -1502,6 +1554,167 @@ export class AssistantDatabase { } } + saveLocalConversations(batch: LocalConversationSaveBatch): void { + const database = this.requireDatabase() + const findConversation = database.prepare( + 'SELECT channel FROM conversations WHERE id = ?' + ) + const insertConversation = database.prepare( + `INSERT INTO conversations + (id, project_id, runtime_selection_json, knowledge_retrieval_mode, + work_mode, title, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)` + ) + const updateConversation = database.prepare( + `UPDATE conversations + SET project_id = ?, runtime_selection_json = ?, + knowledge_retrieval_mode = ?, title = ?, status = 'active', + updated_at = ? + WHERE id = ? AND channel IS NULL` + ) + const findMessage = database.prepare( + `SELECT conversation_id, role + FROM messages + WHERE id = ?` + ) + const nextSequence = database.prepare( + `SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence + FROM messages + WHERE conversation_id = ?` + ) + const insertMessage = database.prepare( + `INSERT INTO messages + (id, conversation_id, request_id, role, content, state, sequence, + metadata_json, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)` + ) + const updateMessage = database.prepare( + `UPDATE messages + SET content = ?, state = ?, metadata_json = ? + WHERE id = ?` + ) + const trimMessages = database.prepare( + `DELETE FROM messages + WHERE id IN ( + SELECT id + FROM messages + WHERE conversation_id = ? + ORDER BY sequence DESC + LIMIT -1 OFFSET 500 + )` + ) + + database.exec('BEGIN IMMEDIATE') + try { + for (const save of batch) { + const { header } = save + const existingConversation = findConversation.get( + header.id + ) as { channel: ProjectChannel | null } | undefined + const updatedAt = new Date(header.updatedAt).toISOString() + if (existingConversation?.channel) { + throw new Error('本地对话 ID 与远程对话冲突') + } + if (existingConversation) { + const result = updateConversation.run( + header.projectId ?? null, + header.runtimeSelection + ? JSON.stringify(header.runtimeSelection) + : null, + header.knowledgeRetrievalMode ?? null, + header.title, + updatedAt, + header.id + ) + if (result.changes !== 1) { + throw new Error('无法更新本地对话') + } + } else { + insertConversation.run( + header.id, + header.projectId ?? null, + header.runtimeSelection + ? JSON.stringify(header.runtimeSelection) + : null, + header.knowledgeRetrievalMode ?? null, + header.title, + updatedAt, + updatedAt + ) + } + + let sequence = ( + nextSequence.get(header.id) as { sequence: number } + ).sequence + let insertedMessage = false + for (const message of save.messages) { + const existingMessage = findMessage.get(message.id) as + | { + conversation_id: string + role: MessageRow['role'] + } + | undefined + if (existingMessage) { + if (existingMessage.conversation_id !== header.id) { + throw new Error('消息 ID 已属于其他对话') + } + if (existingMessage.role !== message.role) { + throw new Error('消息角色不能更改') + } + updateMessage.run( + message.content, + message.state, + serializeConversationMessageMetadata(message), + message.id + ) + continue + } + insertMessage.run( + message.id, + header.id, + message.role, + message.content, + message.state, + sequence, + serializeConversationMessageMetadata(message), + new Date(message.createdAt).toISOString() + ) + sequence += 1 + insertedMessage = true + } + if (insertedMessage) { + trimMessages.run(header.id) + } + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + + deleteLocalConversation(conversationId: string): boolean { + const database = this.requireDatabase() + const conversation = database + .prepare('SELECT channel FROM conversations WHERE id = ?') + .get(conversationId) as + | { channel: ProjectChannel | null } + | undefined + if (!conversation) { + return false + } + if (conversation.channel) { + throw new Error('远程对话不能作为本地对话删除') + } + return ( + database + .prepare( + 'DELETE FROM conversations WHERE id = ? AND channel IS NULL' + ) + .run(conversationId).changes === 1 + ) + } + getOrCreateRemoteConversation(input: { projectId: string channel: ProjectChannel diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 2b33621..9e3ef93 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject } from '../shared/assistant-contracts' -import type { BrowserLiveState } from '../shared/contracts' +import type { AgentEvent, BrowserLiveState } from '../shared/contracts' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' import { registerIpcHandlers } from './ipc' @@ -1715,6 +1715,177 @@ describe('registerIpcHandlers token usage', () => { }) }) +describe('registerIpcHandlers local conversation persistence', () => { + afterEach(() => { + electronMocks.handlers.clear() + vi.clearAllMocks() + }) + + it('validates and forwards incremental saves and explicit deletions', async () => { + const assistantDatabase = { + claimDueSchedules: vi.fn(() => []), + saveLocalConversations: vi.fn(), + deleteLocalConversation: vi.fn(() => true) + } + const webContents = { + mainFrame: { + url: 'file:///goodbuddy/index.html' + }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html') + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + assistantDatabase as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => {}) + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + const conversationId = + '00000000-0000-4000-8000-000000000301' + const messageId = '00000000-0000-4000-8000-000000000302' + const batch = [ + { + header: { + id: conversationId, + title: '增量会话', + updatedAt: 1 + }, + messages: [ + { + id: messageId, + role: 'assistant' as const, + content: '增量内容', + createdAt: 1, + state: 'streaming' as const + } + ] + } + ] + + expect( + electronMocks.handlers.get( + ipcChannels.conversationsSaveLocal + )?.(event, batch) + ).toBeUndefined() + expect( + assistantDatabase.saveLocalConversations + ).toHaveBeenCalledWith(batch) + expect( + electronMocks.handlers.get( + ipcChannels.conversationsDeleteLocal + )?.(event, conversationId) + ).toBe(true) + expect( + assistantDatabase.deleteLocalConversation + ).toHaveBeenCalledWith(conversationId) + + expect(() => + electronMocks.handlers.get( + ipcChannels.conversationsSaveLocal + )?.(event, [ + { + ...batch[0], + header: { + ...batch[0]!.header, + remote: { + channel: 'weixin', + accountDisplay: 'remote', + conversationType: 'direct' + } + } + } + ]) + ).toThrow() + expect(() => + electronMocks.handlers.get( + ipcChannels.conversationsDeleteLocal + )?.(event, 'not-a-uuid') + ).toThrow() + + await dispose() + }) + + it('waits for the renderer persistence acknowledgement before removing handlers', async () => { + const assistantDatabase = { + claimDueSchedules: vi.fn(() => []) + } + const webContents = { + mainFrame: { + url: 'file:///goodbuddy/index.html' + }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + assistantDatabase as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => {}) + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + electronMocks.handlers.get( + ipcChannels.appRendererPersistenceReady + )?.(event) + + const disposal = dispose() + await vi.waitFor(() => + expect(webContents.send).toHaveBeenCalledWith( + ipcChannels.appRendererPersistenceRequest, + expect.any(String) + ) + ) + expect( + electronMocks.handlers.has(ipcChannels.conversationsSaveLocal) + ).toBe(true) + const requestId = webContents.send.mock.calls.find( + ([channel]) => + channel === ipcChannels.appRendererPersistenceRequest + )?.[1] + expect(requestId).toEqual(expect.any(String)) + electronMocks.handlers.get( + ipcChannels.appRendererPersistenceComplete + )?.(event, requestId) + + await disposal + expect( + electronMocks.handlers.has(ipcChannels.conversationsSaveLocal) + ).toBe(false) + }) +}) + describe('registerIpcHandlers agent terminal state', () => { afterEach(() => { electronMocks.handlers.clear() @@ -2258,6 +2429,105 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('coalesces burst deltas while preserving output and terminal order', async () => { + const deltas = Array.from( + { length: 100 }, + (_, index) => `chunk-${index};` + ) + const expectedOutput = deltas.join('') + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + async *run(request: { requestId: string }) { + for (const delta of deltas) { + yield { + requestId: request.requestId, + type: 'text', + delta + } + } + yield { + requestId: request.requestId, + type: 'tool', + callId: 'call-burst', + name: 'read', + state: 'completed', + summary: 'read completed' + } + yield { requestId: request.requestId, type: 'done' } + } + } + const harness = createHarness(runtime) + const requestId = '00000000-0000-4000-8000-000000000025' + + await harness.handler?.(trustedEvent(harness.webContents), { + requestId, + conversationId: 'burst-stream', + prompt: 'stream', + workMode: 'ask', + knowledgeLibraryIds: [] + }) + await vi.waitFor(() => + expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( + requestId, + 'completed' + ) + ) + + const persistedEvents = + harness.assistantDatabase.appendTaskEvent.mock.calls + .filter(([taskId]) => taskId === requestId) + .map(([, , payload]) => payload) + const publicEvents = harness.webContents.send.mock.calls + .filter(([channel]) => channel === ipcChannels.agentEvent) + .map(([, payload]) => payload) + .filter((payload) => payload.requestId === requestId) + expect(persistedEvents).toHaveLength(3) + expect(publicEvents).toHaveLength(4) + expect(persistedEvents.map((event) => event.type)).toEqual([ + 'text', + 'tool', + 'done' + ]) + expect(publicEvents.map((event) => event.type)).toEqual([ + 'text', + 'text', + 'tool', + 'done' + ]) + expect(publicEvents[0]).toEqual({ + requestId, + type: 'text', + delta: deltas[0] + }) + expect(persistedEvents[0]).toEqual({ + requestId, + type: 'text', + delta: expectedOutput + }) + expect( + publicEvents + .filter( + ( + event + ): event is Extract => + event.type === 'text' + ) + .map((event) => event.delta) + .join('') + ).toBe(expectedOutput) + expect( + harness.assistantDatabase.createTextArtifact + ).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: requestId, + content: expectedOutput + }) + ) + await harness.dispose() + }) + it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const documentId = '33333333-3333-4333-8333-333333333333' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2a9f7af..778fb50 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -131,6 +131,7 @@ import { import { assistantIdSchema, conversationSnapshotsSchema, + localConversationSaveBatchSchema, memoryCreateSchema, normalizeInteractiveWorkMode, projectChannelLabels, @@ -241,6 +242,7 @@ import { analyzeMagicNoteEntry, analyzeMagicTodo } from './magic-notes/magic-note-analyzer' +import { AgentEventBuffer } from './agent-event-buffer' const requestIdSchema = z.string().uuid() const GOODBUDDY_RELEASES_URL = @@ -832,6 +834,7 @@ export function registerIpcHandlers( goodbuddyConfigService?: GoodBuddyConfigService ): () => Promise { const activeRequests = new Map() + const activeEventBuffers = new Map() const pendingAgentQuestions = new Map< string, { requestId: string; runtime: AgentRuntime } @@ -840,6 +843,8 @@ export function registerIpcHandlers( let shuttingDown = false let executionPaused = false let clearLocalDataOperation: Promise | undefined + let rendererPersistenceReady = false + const pendingRendererPersistence = new Map void>() let pendingGoodBuddyConfigReload = false let goodBuddyConfigReloadQueue: Promise = Promise.resolve() const executionTracker = createPromiseTracker() @@ -902,6 +907,40 @@ export function registerIpcHandlers( ipcMain.removeHandler(channel) } + const requestRendererPersistence = async (): Promise => { + if ( + !rendererPersistenceReady || + window.isDestroyed() || + (typeof window.webContents.isDestroyed === 'function' && + window.webContents.isDestroyed()) + ) { + return + } + const requestId = randomUUID() + const completion = new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timeout) + pendingRendererPersistence.delete(requestId) + resolve() + } + pendingRendererPersistence.set(requestId, finish) + const timeout = setTimeout(finish, 1_500) + timeout.unref?.() + }) + window.webContents.send( + ipcChannels.appRendererPersistenceRequest, + requestId + ) + await completion + } + + const waitForRendererQuiescence = async (): Promise => { + await Promise.allSettled([ + executionTracker.drain(), + maintenanceTracker.drain() + ]) + } + const notifyMaximizedChanged = (): void => { if (!window.isDestroyed()) { window.webContents.send( @@ -967,6 +1006,7 @@ export function registerIpcHandlers( }, signal, (approvalEvent) => { + activeEventBuffers.get(event.requestId)?.flush() if (!window.isDestroyed()) { window.webContents.send(ipcChannels.agentEvent, approvalEvent) } @@ -1029,6 +1069,7 @@ export function registerIpcHandlers( parentTaskId: string, event: Extract ): void => { + activeEventBuffers.get(parentTaskId)?.flush() assistantDatabase.appendTaskEvent( parentTaskId, event.type, @@ -1218,6 +1259,16 @@ export function registerIpcHandlers( let knowledgeCapabilityToken: string | undefined const resultAttachments: ChannelMediaAttachment[] = [] const artifactIds: string[] = [] + const eventBuffer = new AgentEventBuffer({ + onError: (error) => controller.abort(error), + onEvent: (event) => { + assistantDatabase.appendTaskEvent( + requestId, + event.type, + event + ) + } + }) try { const requestRuntime = remoteContext?.runtime ?? @@ -1296,6 +1347,7 @@ export function registerIpcHandlers( }, controller.signal, (approvalEvent) => { + eventBuffer.flush() if (!window.isDestroyed()) { window.webContents.send( ipcChannels.agentEvent, @@ -1380,11 +1432,7 @@ export function registerIpcHandlers( if (taskEvent.type === 'artifact') { artifactIds.push(taskEvent.artifactId) } - assistantDatabase.appendTaskEvent( - requestId, - taskEvent.type, - taskEvent - ) + eventBuffer.push(taskEvent) if (taskEvent.type === 'tool' && remoteContext) { publishRemoteActivity({ requestId, @@ -1474,6 +1522,7 @@ export function registerIpcHandlers( ...(artifactIds.length > 0 ? { artifactIds } : {}) } } catch (error) { + eventBuffer.flush() const message = safeRuntimeError(error, '定时任务执行失败') assistantDatabase.updateTaskStatus( requestId, @@ -1492,6 +1541,7 @@ export function registerIpcHandlers( }) return { status: 'failed', error: message } } finally { + eventBuffer.close() externalSignal?.removeEventListener( 'abort', abortFromExternal @@ -2040,6 +2090,25 @@ export function registerIpcHandlers( } }) + registerHandler( + ipcChannels.appRendererPersistenceReady, + (event) => { + assertTrustedSender(event, window) + rendererPersistenceReady = true + }, + false + ) + + registerHandler( + ipcChannels.appRendererPersistenceComplete, + (event, input: unknown) => { + assertTrustedSender(event, window) + const requestId = requestIdSchema.parse(input) + pendingRendererPersistence.get(requestId)?.() + }, + false + ) + registerHandler(ipcChannels.appShow, (event) => { assertTrustedSender(event, window) showWindow(window) @@ -2280,10 +2349,58 @@ export function registerIpcHandlers( const execution = (async () => { let outputText = '' let completed = false - let persistedRuntimeError = false + let runtimeErrorEvent: + | Extract + | undefined let executionRequest = request let preflightReferences: KnowledgeSearchReference[] = [] let referencesPublished = false + const persistedEventBuffer = new AgentEventBuffer({ + onError: (error) => controller.abort(error), + onEvent: (event) => { + assistantDatabase.appendTaskEvent( + request.requestId, + event.type, + event + ) + } + }) + const publicEventBuffer = new AgentEventBuffer({ + flushIntervalMs: 16, + onError: (error) => controller.abort(error), + onEvent: (event) => { + if (!window.isDestroyed()) { + window.webContents.send(ipcChannels.agentEvent, event) + } + } + }) + let publicStreamType: 'text' | 'reasoning' | undefined + const eventBuffer = { + push: (event: AgentEvent): void => { + const streamType = + event.type === 'text' || event.type === 'reasoning' + ? event.type + : undefined + const startsStreamSegment = + streamType !== undefined && streamType !== publicStreamType + publicStreamType = streamType + publicEventBuffer.push(event) + if (startsStreamSegment) { + publicEventBuffer.flush() + } + persistedEventBuffer.push(event) + }, + flush: (): void => { + publicStreamType = undefined + publicEventBuffer.flush() + persistedEventBuffer.flush() + }, + close: (): void => { + publicEventBuffer.close() + persistedEventBuffer.close() + } + } + activeEventBuffers.set(request.requestId, eventBuffer) const toolStates = new Map< string, Extract @@ -2294,17 +2411,7 @@ export function registerIpcHandlers( { type: 'knowledge-retrieval' } > ): void => { - assistantDatabase.appendTaskEvent( - request.requestId, - retrievalEvent.type, - retrievalEvent - ) - if (!window.isDestroyed()) { - window.webContents.send( - ipcChannels.agentEvent, - retrievalEvent - ) - } + eventBuffer.push(retrievalEvent) } const publishReferences = (): void => { if (referencesPublished) { @@ -2337,17 +2444,7 @@ export function registerIpcHandlers( type: 'source-references', references } - assistantDatabase.appendTaskEvent( - request.requestId, - referenceEvent.type, - referenceEvent - ) - if (!window.isDestroyed()) { - window.webContents.send( - ipcChannels.agentEvent, - referenceEvent - ) - } + eventBuffer.push(referenceEvent) } try { controller.signal.throwIfAborted() @@ -2593,12 +2690,7 @@ export function registerIpcHandlers( }) } if (publicEvent.type === 'error') { - assistantDatabase.appendTaskEvent( - request.requestId, - publicEvent.type, - publicEvent - ) - persistedRuntimeError = true + runtimeErrorEvent = publicEvent throw new Error(publicEvent.message) } if (publicEvent.type === 'done') { @@ -2615,12 +2707,15 @@ export function registerIpcHandlers( ) } publishReferences() + eventBuffer.flush() + assistantDatabase.appendTaskEvent( + request.requestId, + publicEvent.type, + publicEvent + ) + } else { + eventBuffer.push(publicEvent) } - assistantDatabase.appendTaskEvent( - request.requestId, - publicEvent.type, - publicEvent - ) if (publicEvent.type === 'done') { completed = true if (outputText.trim()) { @@ -2641,9 +2736,12 @@ export function registerIpcHandlers( title: 'GoodBuddy 任务已完成', body: '任务结果已保存到成果工作栏。' }) - } - if (!window.isDestroyed()) { - window.webContents.send(ipcChannels.agentEvent, publicEvent) + if (!window.isDestroyed()) { + window.webContents.send( + ipcChannels.agentEvent, + publicEvent + ) + } } if (completed) { break @@ -2654,27 +2752,31 @@ export function registerIpcHandlers( } } catch (error) { publishReferences() + eventBuffer.flush() const errorMessage = controller.signal.aborted ? '请求已取消' : safeRuntimeError(error, 'Agent Runtime 执行失败') + const agentEvent: AgentEvent = + runtimeErrorEvent && !controller.signal.aborted + ? runtimeErrorEvent + : { + requestId: request.requestId, + type: 'error', + status: controller.signal.aborted + ? 'cancelled' + : 'failed', + message: errorMessage + } assistantDatabase.updateTaskStatus( request.requestId, controller.signal.aborted ? 'cancelled' : 'failed', errorMessage ) - const agentEvent: AgentEvent = { - requestId: request.requestId, - type: 'error', - status: controller.signal.aborted ? 'cancelled' : 'failed', - message: errorMessage - } - if (!persistedRuntimeError) { - assistantDatabase.appendTaskEvent( - request.requestId, - agentEvent.type, - agentEvent - ) - } + assistantDatabase.appendTaskEvent( + request.requestId, + agentEvent.type, + agentEvent + ) showDesktopNotificationWhenUnfocused(window, { title: controller.signal.aborted ? 'GoodBuddy 任务已取消' @@ -2685,6 +2787,8 @@ export function registerIpcHandlers( window.webContents.send(ipcChannels.agentEvent, agentEvent) } } finally { + eventBuffer.close() + activeEventBuffers.delete(request.requestId) for (const [questionId, pending] of pendingAgentQuestions) { if (pending.requestId === request.requestId) { pendingAgentQuestions.delete(questionId) @@ -3573,6 +3677,26 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.conversationsSaveLocal, + (event, input: unknown) => { + assertTrustedSender(event, window) + assistantDatabase.saveLocalConversations( + localConversationSaveBatchSchema.parse(input) + ) + } + ) + + registerHandler( + ipcChannels.conversationsDeleteLocal, + (event, input: unknown) => { + assertTrustedSender(event, window) + return assistantDatabase.deleteLocalConversation( + assistantIdSchema.parse(input) + ) + } + ) + registerHandler( ipcChannels.workspaceChangesGet, async (event, input: unknown) => { @@ -5000,9 +5124,6 @@ export function registerIpcHandlers( clearInterval(scheduleInterval) window.removeListener('maximize', notifyMaximizedChanged) window.removeListener('unmaximize', notifyMaximizedChanged) - for (const channel of channels) { - ipcMain.removeHandler(channel) - } abortActiveRequests('应用正在退出') for (const controller of heartbeatControllers) { controller.abort(new Error('应用正在退出')) @@ -5019,6 +5140,16 @@ export function registerIpcHandlers( approvalBroker.clear() goodbuddyConfigService?.clear() pendingGoodBuddyConfigReload = false + await waitForRendererQuiescence() + await requestRendererPersistence() + for (const channel of channels) { + ipcMain.removeHandler(channel) + } + rendererPersistenceReady = false + for (const complete of pendingRendererPersistence.values()) { + complete() + } + pendingRendererPersistence.clear() await goodBuddyConfigReloadQueue const channelCleanup = Promise.allSettled([ ...channelServices.map((service) => service.stop()), diff --git a/src/preload/index.ts b/src/preload/index.ts index 5c940fb..cc8af1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -43,6 +43,7 @@ import type { AssistantTask, TokenUsageSummary, ConversationSnapshot, + LocalConversationSaveBatch, WorkspaceChanges, WorkspaceDirectoryListing, WorkspaceFilePreview, @@ -146,6 +147,30 @@ const desktopApi: DesktopApi = { handler ) }, + onBeforeQuit: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + requestId: string + ): void => { + const acknowledge = (): void => { + void ipcRenderer.invoke( + ipcChannels.appRendererPersistenceComplete, + requestId + ) + } + void listener().then(acknowledge, acknowledge) + } + ipcRenderer.on( + ipcChannels.appRendererPersistenceRequest, + handler + ) + void ipcRenderer.invoke(ipcChannels.appRendererPersistenceReady) + return () => + ipcRenderer.removeListener( + ipcChannels.appRendererPersistenceRequest, + handler + ) + }, clearLocalData: async () => { await ipcRenderer.invoke(ipcChannels.appClearLocalData) }, @@ -555,6 +580,17 @@ const desktopApi: DesktopApi = { conversations ) }, + saveLocal: async (batch: LocalConversationSaveBatch) => { + await ipcRenderer.invoke( + ipcChannels.conversationsSaveLocal, + batch + ) + }, + deleteLocal: (conversationId: string) => + ipcRenderer.invoke( + ipcChannels.conversationsDeleteLocal, + conversationId + ) as Promise, onChanged: (listener) => { const handler = (): void => listener() ipcRenderer.on(ipcChannels.conversationsChanged, handler) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 2d4d782..a1ec3b2 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -20,6 +20,26 @@ const speechRecognitionMocks = vi.hoisted(() => ({ startPcmRecording: vi.fn() })) +const lazyRouteMocks = vi.hoisted(() => { + let pending: Promise | undefined + let releasePending: (() => void) | undefined + return { + suspendKnowledgeRoute(): void { + pending = new Promise((resolve) => { + releasePending = resolve + }) + }, + releaseKnowledgeRoute(): void { + releasePending?.() + releasePending = undefined + pending = undefined + }, + async waitForKnowledgeRoute(): Promise { + await pending + } + } +}) + vi.mock('./speech-recognition', async (importOriginal) => ({ ...(await importOriginal< typeof import('./speech-recognition') @@ -27,6 +47,11 @@ vi.mock('./speech-recognition', async (importOriginal) => ({ startPcmRecording: speechRecognitionMocks.startPcmRecording })) +vi.mock('./KnowledgeWorkspace', async (importOriginal) => { + await lazyRouteMocks.waitForKnowledgeRoute() + return importOriginal() +}) + import App from './App' import { loadActivityRecords } from './activity-store' import { changeUiLocale } from './i18n' @@ -41,6 +66,7 @@ let fileSelectionProgressListener: | undefined let newConversationListener: (() => void) | undefined let maximizedChangedListener: ((maximized: boolean) => void) | undefined +let beforeQuitListener: (() => Promise) | undefined const removeMaximizedChangedListener = vi.fn() const run = vi.fn() const modelProfileId = '00000000-0000-4000-8000-000000000001' @@ -76,6 +102,12 @@ const api: DesktopApi = { maximizedChangedListener = listener return removeMaximizedChangedListener }), + onBeforeQuit: vi.fn((listener) => { + beforeQuitListener = listener + return () => { + beforeQuitListener = undefined + } + }), clearLocalData: vi.fn(async () => {}), onNewConversation: vi.fn((listener) => { newConversationListener = listener @@ -289,6 +321,8 @@ const api: DesktopApi = { conversations: { list: vi.fn(async () => []), replace: vi.fn(async () => {}), + saveLocal: vi.fn(async () => {}), + deleteLocal: vi.fn(async () => true), onChanged: vi.fn(() => () => undefined) }, workspace: { @@ -664,7 +698,21 @@ describe('App', () => { delete document.documentElement.dataset.theme document.documentElement.style.colorScheme = '' vi.clearAllMocks() + vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]) + vi.mocked(api.conversations.replace) + .mockReset() + .mockResolvedValue() + vi.mocked(api.conversations.saveLocal) + .mockReset() + .mockResolvedValue() + vi.mocked(api.conversations.deleteLocal) + .mockReset() + .mockResolvedValue(true) + vi.mocked(api.conversations.onChanged) + .mockReset() + .mockReturnValue(() => undefined) newConversationListener = undefined + beforeQuitListener = undefined browserListener = undefined fileSelectionProgressListener = undefined maximizedChangedListener = undefined @@ -774,6 +822,122 @@ describe('App', () => { expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument() }) + it('shows an accessible fallback while a lazy route loads', async () => { + lazyRouteMocks.suspendKnowledgeRoute() + try { + render() + + fireEvent.click( + await screen.findByRole('button', { name: '知识库' }) + ) + + const loading = screen.getByRole('status', { + name: '正在加载页面…' + }) + expect(loading).toHaveAttribute('aria-live', 'polite') + expect(loading).toHaveAttribute('aria-busy', 'true') + await act(async () => lazyRouteMocks.releaseKnowledgeRoute()) + expect( + await screen.findByRole('heading', { + level: 1, + name: '知识库' + }) + ).toBeInTheDocument() + expect( + screen.queryByRole('status', { name: '正在加载页面…' }) + ).not.toBeInTheDocument() + } finally { + lazyRouteMocks.releaseKnowledgeRoute() + } + }) + + it('preserves title, message, and project filtering with deferred search', async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: '00000000-0000-4000-8000-000000000411', + projectId, + title: '标题里的 Alpha', + updatedAt: 1_775_000_000_003, + messages: [ + { + id: '00000000-0000-4000-8000-000000000412', + role: 'assistant', + content: '普通正文', + createdAt: 1_775_000_000_003, + state: 'complete' + } + ] + }, + { + id: '00000000-0000-4000-8000-000000000413', + projectId, + title: '正文命中的会话', + updatedAt: 1_775_000_000_002, + messages: [ + { + id: '00000000-0000-4000-8000-000000000414', + role: 'user', + content: '这里包含 Beta Needle', + createdAt: 1_775_000_000_002, + state: 'complete' + } + ] + }, + { + id: '00000000-0000-4000-8000-000000000415', + projectId: '00000000-0000-4000-8000-000000000999', + title: '其他项目里的 Alpha', + updatedAt: 1_775_000_000_001, + messages: [ + { + id: '00000000-0000-4000-8000-000000000416', + role: 'user', + content: 'Beta Needle', + createdAt: 1_775_000_000_001, + state: 'complete' + } + ] + } + ]) + const { container } = render() + const search = await screen.findByLabelText('搜索对话') + const conversationList = + container.querySelector('.conversation-list') + if (!conversationList) { + throw new Error('Missing conversation list') + } + expect( + await within(conversationList).findByText('标题里的 Alpha') + ).toBeInTheDocument() + expect( + within(conversationList).queryByText('其他项目里的 Alpha') + ).not.toBeInTheDocument() + + fireEvent.change(search, { target: { value: 'beta needle' } }) + expect(search).toHaveValue('beta needle') + await waitFor(() => { + expect( + within(conversationList).getByText('正文命中的会话') + ).toBeInTheDocument() + expect( + within(conversationList).queryByText('标题里的 Alpha') + ).not.toBeInTheDocument() + }) + + fireEvent.change(search, { target: { value: 'ALPHA' } }) + await waitFor(() => { + expect( + within(conversationList).getByText('标题里的 Alpha') + ).toBeInTheDocument() + expect( + within(conversationList).queryByText('正文命中的会话') + ).not.toBeInTheDocument() + expect( + within(conversationList).queryByText('其他项目里的 Alpha') + ).not.toBeInTheDocument() + }) + }) + it('keeps Settings open when the interface language changes', async () => { api.updates = { getSettings: vi.fn(async () => ({ @@ -1225,6 +1389,69 @@ describe('App', () => { ).not.toBeInTheDocument() }) + it('renders the latest 80 messages and preserves scroll when revealing earlier messages', async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: '00000000-0000-4000-8000-000000000421', + projectId, + title: '超长会话', + updatedAt: 1_775_000_000_000, + messages: Array.from({ length: 161 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `历史消息 ${String(index).padStart(3, '0')}`, + createdAt: 1_775_000_000_000 + index, + state: 'complete' as const + })) + } + ]) + const { container } = render() + + expect(await screen.findByText('历史消息 160')).toBeInTheDocument() + expect(screen.queryByText('历史消息 080')).not.toBeInTheDocument() + expect(container.querySelectorAll('.message')).toHaveLength(80) + const chat = container.querySelector('.chat') + if (!chat) { + throw new Error('Missing chat scroll container') + } + Object.defineProperties(chat, { + clientHeight: { configurable: true, value: 400 }, + scrollHeight: { + configurable: true, + get: () => container.querySelectorAll('.message').length * 10 + }, + scrollTop: { + configurable: true, + writable: true, + value: 125 + } + }) + + fireEvent.click( + screen.getByRole('button', { + name: '加载更早的消息(还剩 81 条)' + }) + ) + + expect(container.querySelectorAll('.message')).toHaveLength(160) + expect(screen.getByText('历史消息 001')).toBeInTheDocument() + expect(screen.queryByText('历史消息 000')).not.toBeInTheDocument() + expect(chat.scrollTop).toBe(925) + fireEvent.click( + screen.getByRole('button', { + name: '加载更早的消息(还剩 1 条)' + }) + ) + expect(container.querySelectorAll('.message')).toHaveLength(161) + expect(screen.getByText('历史消息 000')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /加载更早的消息/u }) + ).not.toBeInTheDocument() + expect( + screen.getByText('历史消息 000').closest('article') + ).toHaveFocus() + }) + it('keeps the reader position while a response continues below', async () => { render() fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { @@ -1335,6 +1562,68 @@ describe('App', () => { }) ).not.toBeInTheDocument() ) + expect(api.conversations.deleteLocal).toHaveBeenCalledWith( + expect.any(String) + ) + }) + + it('keeps a local conversation visible when deleting its persisted record fails', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000431' + const title = '删除失败时保留的本地会话' + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: conversationId, + projectId, + title, + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000432', + role: 'assistant', + content: '需要保留的消息', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + vi.mocked(api.conversations.deleteLocal).mockRejectedValueOnce( + new Error('delete failed') + ) + render() + + fireEvent.click( + await screen.findByLabelText(`更多会话操作 ${title}`) + ) + fireEvent.click( + screen.getByRole('button', { name: `删除对话 ${title}` }) + ) + fireEvent.click( + screen.getByRole('button', { + name: `确认永久删除对话 ${title}` + }) + ) + + await waitFor(() => + expect(api.conversations.deleteLocal).toHaveBeenCalledWith( + conversationId + ) + ) + expect( + await screen.findByText('删除本地会话失败,已保留当前对话') + ).toBeInTheDocument() + expect(screen.getByText('需要保留的消息')).toBeInTheDocument() + const dialog = screen.getByRole('alertdialog', { + name: `确认永久删除对话 ${title}` + }) + expect( + dialog + ).toBeInTheDocument() + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect( + await screen.findByLabelText(`更多会话操作 ${title}`) + ).toBeInTheDocument() }) it('keeps a conversation when cancelling its active task fails', async () => { @@ -1576,6 +1865,155 @@ describe('App', () => { expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge') }) + it('persists only the changed conversation and streamed assistant message', async () => { + const activeConversationId = + '00000000-0000-4000-8000-000000000441' + const activeMessageId = + '00000000-0000-4000-8000-000000000442' + const unrelatedConversationId = + '00000000-0000-4000-8000-000000000443' + const unrelatedMessageId = + '00000000-0000-4000-8000-000000000444' + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: activeConversationId, + projectId, + title: '增量持久化会话', + updatedAt: 1_775_000_000_002, + messages: [ + { + id: activeMessageId, + role: 'assistant', + content: '原有消息', + createdAt: 1_775_000_000_002, + state: 'complete' + } + ] + }, + { + id: unrelatedConversationId, + projectId, + title: '无关会话', + updatedAt: 1_775_000_000_001, + messages: [ + { + id: unrelatedMessageId, + role: 'assistant', + content: '不应重复保存', + createdAt: 1_775_000_000_001, + state: 'complete' + } + ] + } + ]) + render() + expect(await screen.findByText('原有消息')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '只更新当前会话' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + await waitFor(() => + expect(api.conversations.saveLocal).toHaveBeenCalled() + ) + vi.mocked(api.conversations.saveLocal).mockClear() + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'text', + delta: '流式增量' + }) + }) + + await waitFor( + () => { + const batch = vi + .mocked(api.conversations.saveLocal) + .mock.calls.at(-1)?.[0] + expect(batch).toHaveLength(1) + expect(batch?.[0]?.header.id).toBe(activeConversationId) + expect(batch?.[0]?.messages).toEqual([ + expect.objectContaining({ + role: 'assistant', + content: '流式增量', + state: 'streaming' + }) + ]) + expect( + batch?.some( + (entry) => entry.header.id === unrelatedConversationId + ) + ).toBe(false) + expect( + batch?.flatMap((entry) => entry.messages).some( + (message) => + message.id === activeMessageId || + message.id === unrelatedMessageId || + message.role === 'user' + ) + ).toBe(false) + }, + { timeout: 2_000 } + ) + }) + + it('flushes pending local conversation changes before quit', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000445' + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: conversationId, + projectId, + title: '退出持久化会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000446', + role: 'assistant', + content: '已有内容', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + render() + expect(await screen.findByText('已有内容')).toBeInTheDocument() + vi.mocked(api.conversations.saveLocal).mockClear() + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '退出前必须保存' } + }) + fireEvent.click(screen.getByLabelText('发送')) + expect( + await screen.findByText('退出前必须保存') + ).toBeInTheDocument() + if (!beforeQuitListener) { + throw new Error('Missing before-quit persistence listener') + } + + await act(async () => beforeQuitListener?.()) + + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + expect.objectContaining({ + header: expect.objectContaining({ id: conversationId }), + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: '退出前必须保存', + state: 'complete' + }) + ]) + }) + ]) + }) + it('replaces the direct-model thinking status with real reasoning', async () => { render() @@ -1773,9 +2211,9 @@ describe('App', () => { await waitFor( () => { const persistedMessages = vi - .mocked(api.conversations.replace) - .mock.calls.flatMap(([conversations]) => - conversations.flatMap((conversation) => conversation.messages) + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => + batch.flatMap((conversation) => conversation.messages) ) const persisted = persistedMessages .filter((message) => message.role === 'assistant') @@ -1864,12 +2302,12 @@ describe('App', () => { expect(screen.getByText('向量模型未配置')).toBeInTheDocument() await waitFor(() => { const snapshots = vi - .mocked(api.conversations.replace) - .mock.calls.at(-1)?.[0] + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => batch) expect( snapshots?.some( (conversation) => - conversation.knowledgeRetrievalMode === 'always' + conversation.header.knowledgeRetrievalMode === 'always' ) ).toBe(true) }) @@ -2007,7 +2445,7 @@ describe('App', () => { expect(anchorClick).toHaveBeenCalledOnce() await waitFor( () => - expect(api.conversations.replace).toHaveBeenCalledWith( + expect(api.conversations.saveLocal).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ messages: expect.arrayContaining([ @@ -2844,14 +3282,14 @@ describe('App', () => { setTimeout(resolve, 550) }) ) - expect(api.conversations.replace).not.toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - projectId: channelProject.id, - remote: undefined - }) - ]) - ) + const savedChannelHeaders = vi + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header)) + expect( + savedChannelHeaders.some( + (header) => header.projectId === channelProject.id + ) + ).toBe(false) expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: '打开设置' })) @@ -3447,10 +3885,238 @@ describe('App', () => { ) }) + it('persists metadata-only retrieval and Runtime changes without rewriting messages', async () => { + const conversationId = + '00000000-0000-4000-8000-000000000451' + const existingMessageId = + '00000000-0000-4000-8000-000000000452' + const libraryId = '11111111-1111-4111-8111-111111111111' + const secondProfileId = + '00000000-0000-4000-8000-000000000453' + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + modelProfiles: [ + ...settings.modelProfiles, + { + id: secondProfileId, + name: '仅元数据模型', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none', + imageGenerationQuality: 'auto', + apiKeyConfigured: false, + credentialSource: 'none' + } + ] + }) + vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ + libraries: [ + { + id: libraryId, + name: '产品知识', + description: '', + storageMode: 'managed', + graphEnabled: false, + graphStrategy: 'rules', + sourceCount: 1, + documentCount: 1, + indexedDocumentCount: 1 + } + ], + sources: [], + documents: [], + graphNodes: [], + graphRelations: [], + evidence: [] + }) + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: conversationId, + projectId, + runtimeSelection: { + provider: 'model', + profileId: modelProfileId + }, + knowledgeRetrievalMode: 'auto', + title: '元数据会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: existingMessageId, + role: 'assistant', + content: '现有消息不应重写', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + vi.mocked(api.agent.getStatus).mockImplementation( + async (selection) => ({ + id: 'model', + label: + selection?.provider === 'model' && + selection.profileId === secondProfileId + ? 'qwen3' + : 'sonnet-5', + available: true, + supportsToolExecution: true, + detail: 'Ready' + }) + ) + render() + + const knowledgeScope = await screen.findByRole('button', { + name: '选择知识库,本次已启用 1 个' + }) + fireEvent.click(knowledgeScope) + fireEvent.click( + within( + screen.getByRole('group', { name: '知识检索方式' }) + ).getByRole('button', { name: '每次先检索' }) + ) + await waitFor( + () => + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + { + header: expect.objectContaining({ + id: conversationId, + knowledgeRetrievalMode: 'always' + }), + messages: [] + } + ]), + { timeout: 2_000 } + ) + + vi.mocked(api.conversations.saveLocal).mockClear() + fireEvent.click( + screen.getByRole('button', { name: /sonnet-5/u }) + ) + fireEvent.click( + screen.getByRole('menuitemradio', { + name: /^仅元数据模型.*qwen3$/u + }) + ) + await waitFor( + () => + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + { + header: expect.objectContaining({ + id: conversationId, + runtimeSelection: { + provider: 'model', + profileId: secondProfileId + }, + knowledgeRetrievalMode: 'always' + }), + messages: [] + } + ]), + { timeout: 2_000 } + ) + const savedMessages = vi + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => + batch.flatMap((entry) => entry.messages) + ) + expect( + savedMessages.some((message) => message.id === existingMessageId) + ).toBe(false) + }) + + it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => { + const legacyConversation = { + id: '00000000-0000-4000-8000-000000000461', + title: '待迁移旧会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000462', + role: 'assistant' as const, + content: '旧版浏览器存储消息', + createdAt: 1_775_000_000_000, + state: 'complete' as const + } + ] + } + localStorage.setItem( + 'goodbuddy.conversations.v1', + JSON.stringify([legacyConversation]) + ) + render() + + expect( + await screen.findByText('旧版浏览器存储消息') + ).toBeInTheDocument() + await waitFor(() => + expect(api.conversations.replace).toHaveBeenCalledWith([ + expect.objectContaining({ + ...legacyConversation, + projectId + }) + ]) + ) + expect(api.conversations.saveLocal).not.toHaveBeenCalled() + }) + + it('does not let remote rows displace legacy local conversations during migration', async () => { + const legacyConversations = [0, 1].map((index) => ({ + id: `00000000-0000-4000-8000-${String(470 + index).padStart(12, '0')}`, + title: `待迁移本地会话 ${index}`, + updatedAt: 1_775_000_000_000 + index, + messages: [ + { + id: `00000000-0000-4000-8001-${String(470 + index).padStart(12, '0')}`, + role: 'assistant' as const, + content: `待迁移消息 ${index}`, + createdAt: 1_775_000_000_000 + index, + state: 'complete' as const + } + ] + })) + localStorage.setItem( + 'goodbuddy.conversations.v1', + JSON.stringify(legacyConversations) + ) + const remoteProjectId = + '00000000-0000-4000-8000-000000000499' + vi.mocked(api.conversations.list).mockResolvedValueOnce( + Array.from({ length: 100 }, (_, index) => ({ + id: `00000000-0000-4000-8002-${String(index).padStart(12, '0')}`, + projectId: remoteProjectId, + remote: { + channel: 'weixin' as const, + accountDisplay: `远程联系人 ${index}`, + conversationType: 'direct' as const + }, + title: `远程会话 ${index}`, + updatedAt: 1_775_000_100_000 + index, + messages: [] + })) + ) + render() + + await waitFor(() => + expect(api.conversations.saveLocal).toHaveBeenCalled() + ) + expect(api.conversations.replace).not.toHaveBeenCalled() + const migrated = + vi.mocked(api.conversations.saveLocal).mock.calls[0]?.[0] ?? [] + expect(migrated.map((conversation) => conversation.header.id)).toEqual( + expect.arrayContaining( + legacyConversations.map((conversation) => conversation.id) + ) + ) + }) + it('preserves a legacy Auto conversation without silently persisting a replacement', async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: '00000000-0000-4000-8000-000000000020', + projectId, runtimeSelection: { provider: 'auto' }, title: '旧自动对话', updatedAt: 1, @@ -3468,18 +4134,10 @@ describe('App', () => { render() expect( - await screen.findByRole('button', { name: /默认模型.*sonnet-5/u }) + await screen.findByRole('button', { name: /自动.*sonnet-5/u }) ).toBeInTheDocument() - await waitFor(() => - expect(api.conversations.replace).toHaveBeenLastCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: '00000000-0000-4000-8000-000000000020', - runtimeSelection: { provider: 'auto' } - }) - ]) - ) - ) + expect(api.conversations.replace).not.toHaveBeenCalled() + expect(api.conversations.saveLocal).not.toHaveBeenCalled() }) it('keeps a removed model selection visible until the user replaces it', async () => { @@ -3488,6 +4146,7 @@ describe('App', () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: '00000000-0000-4000-8000-000000000022', + projectId, runtimeSelection: { provider: 'model', profileId: removedProfileId @@ -3507,19 +4166,14 @@ describe('App', () => { ]) render() - await waitFor(() => - expect(api.conversations.replace).toHaveBeenLastCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: '00000000-0000-4000-8000-000000000022', - runtimeSelection: { - provider: 'model', - profileId: removedProfileId - } - }) - ]) - ) - ) + expect( + await screen.findByText('旧消息') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /模型配置不可用/u }) + ).toBeInTheDocument() + expect(api.conversations.replace).not.toHaveBeenCalled() + expect(api.conversations.saveLocal).not.toHaveBeenCalled() }) it('keeps model Runtime selection scoped to its conversation', async () => { @@ -3614,14 +4268,17 @@ describe('App', () => { await waitFor( () => - expect(api.conversations.replace).toHaveBeenLastCalledWith( + expect(api.conversations.saveLocal).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ - title: '第二模型对话', - runtimeSelection: { - provider: 'model', - profileId: secondProfileId - } + header: expect.objectContaining({ + title: '第二模型对话', + runtimeSelection: { + provider: 'model', + profileId: secondProfileId + } + }), + messages: expect.any(Array) }) ]) ), @@ -4855,7 +5512,9 @@ describe('App', () => { expect( screen.getByRole('button', { name: '新建笔记' }) ).toBeInTheDocument() - expect(api.magicNotes.list).toHaveBeenCalled() + await waitFor(() => + expect(api.magicNotes.list).toHaveBeenCalled() + ) expect( screen.queryByLabelText('切换助手工作栏') ).not.toBeInTheDocument() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bb9ba5a..3397881 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -38,8 +38,13 @@ import { X } from 'lucide-react' import { + Component, + lazy, + Suspense, useCallback, + useDeferredValue, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -84,10 +89,13 @@ import type { AssistantExpert, AssistantTask, TokenUsageSummary, + ConversationMessage, ConversationSnapshot, ConversationAttachment, ConversationMessageBlock, ConversationToolActivity, + LocalConversationHeader, + LocalConversationSaveBatch, ProjectCreateInput, InteractiveWorkMode, ProjectChannel, @@ -109,13 +117,10 @@ import { upsertActivityRecord, type ActivityRecord } from './activity-store' -import { KnowledgeWorkspace } from './KnowledgeWorkspace' import { KnowledgeCitationDialog, type KnowledgeCitationContextView } from './KnowledgeCitationDialog' -import { HeartbeatCenter } from './HeartbeatCenter' -import { MagicNotesWorkspace } from './MagicNotesWorkspace' import { MarkdownRenderer } from './MarkdownRenderer' import { DestructiveConfirmActions, @@ -133,7 +138,6 @@ import { type PendingSidebarApproval, type SidebarArtifact } from './RightAssistantSidebar' -import { SettingsPanel } from './SettingsPanel' import type { SettingsCategoryId } from './settings-categories' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png' @@ -158,6 +162,30 @@ import type { import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' import { ReleaseNotesDialog } from './ReleaseNotesDialog' +const KnowledgeWorkspace = lazy(async () => { + const module = await import('./KnowledgeWorkspace') + return { default: module.KnowledgeWorkspace } +}) + +const HeartbeatCenter = lazy(async () => { + const module = await import('./HeartbeatCenter') + return { default: module.HeartbeatCenter } +}) + +const MagicNotesWorkspace = lazy(async () => { + const module = await import('./MagicNotesWorkspace') + return { default: module.MagicNotesWorkspace } +}) + +const SettingsPanel = lazy(async () => { + const module = await import('./SettingsPanel') + return { default: module.SettingsPanel } +}) + +const messageRenderBatchSize = 80 +const conversationPersistenceIntervalMs = 500 +const conversationSearchSnapshotDelayMs = 250 + type AppNotification = { id: string message: string @@ -206,6 +234,58 @@ function appNotificationReducer( return [...errors, ...transient] } +function RouteLoadingStatus({ + label +}: { + label: string +}): React.JSX.Element { + return ( +
+
+ ) +} + +class RouteErrorBoundary extends Component< + { children: ReactNode; fallback: ReactNode }, + { failed: boolean } +> { + state = { failed: false } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true } + } + + render(): ReactNode { + return this.state.failed ? this.props.fallback : this.props.children + } +} + +function RouteLoadError({ + message, + reloadLabel +}: { + message: string + reloadLabel: string +}): React.JSX.Element { + return ( +
+
+ ) +} + function AppNotificationItem({ notification, dispatch @@ -735,6 +815,14 @@ function loadConversations( } } +function hasConversationMigrationStorage(): boolean { + try { + return localStorage.getItem(storageKey) !== null + } catch { + return false + } +} + function loadActiveProjectId(): string | undefined { try { return localStorage.getItem(activeProjectStorageKey) || undefined @@ -803,31 +891,91 @@ function isConversation(value: unknown): value is Conversation { function toConversationSnapshots( conversations: Conversation[] ): ConversationSnapshot[] { - return conversations.slice(0, 100).map((conversation) => ({ + return conversations + .filter((conversation) => !conversation.remote) + .slice(0, 100) + .map((conversation) => ({ + id: conversation.id, + projectId: conversation.projectId, + runtimeSelection: conversation.runtimeSelection, + knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + title: conversation.title, + updatedAt: conversation.updatedAt, + messages: conversation.messages + .slice(-500) + .map(toConversationMessage) + })) +} + +function toConversationMessage(message: Message): ConversationMessage { + return { + id: message.id, + role: message.role, + content: message.content, + reasoning: message.reasoning, + blocks: message.blocks, + createdAt: message.createdAt, + state: message.state, + status: message.status, + tools: message.tools, + sources: message.sources, + sourceReferences: message.sourceReferences, + knowledgeRetrieval: message.knowledgeRetrieval, + artifactIds: message.artifactIds, + attachments: message.attachments + } +} + +function toLocalConversationHeader( + conversation: Conversation +): LocalConversationHeader { + return { id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, - remote: conversation.remote, title: conversation.title, - updatedAt: conversation.updatedAt, - messages: conversation.messages.slice(-500).map((message) => ({ - id: message.id, - role: message.role, - content: message.content, - reasoning: message.reasoning, - blocks: message.blocks, - createdAt: message.createdAt, - state: message.state, - status: message.status, - tools: message.tools, - sources: message.sources, - sourceReferences: message.sourceReferences, - knowledgeRetrieval: message.knowledgeRetrieval, - artifactIds: message.artifactIds, - attachments: message.attachments - })) - })) + updatedAt: conversation.updatedAt + } +} + +function createLocalConversationSaveBatch( + conversations: readonly Conversation[], + persisted: ReadonlyMap, + deletingConversationIds: ReadonlySet +): { + batch: LocalConversationSaveBatch + acknowledgements: Conversation[] +} { + const batch: LocalConversationSaveBatch = [] + const acknowledgements: Conversation[] = [] + for (const conversation of conversations) { + if ( + conversation.remote || + deletingConversationIds.has(conversation.id) || + persisted.get(conversation.id) === conversation + ) { + continue + } + const previous = persisted.get(conversation.id) + const previousMessages = new Map( + previous?.messages.map((message) => [message.id, message]) ?? [] + ) + batch.push({ + header: toLocalConversationHeader(conversation), + messages: conversation.messages + .filter( + (message) => previousMessages.get(message.id) !== message + ) + .slice(-500) + .map(toConversationMessage) + }) + acknowledgements.push(conversation) + if (batch.length === 100) { + break + } + } + return { batch, acknowledgements } } function mergeArtifacts( @@ -1307,6 +1455,9 @@ function App(): React.JSX.Element { tRef.current = t }, [t]) const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN' + const conversationMigrationStoragePresent = useRef( + hasConversationMigrationStorage() + ) const [conversations, setConversations] = useState(() => loadConversations( t('conversation.greeting'), @@ -1316,6 +1467,14 @@ function App(): React.JSX.Element { const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '') const activeConversationIdRef = useRef(activeId) const conversationsRef = useRef(conversations) + const persistedLocalConversationsRef = useRef( + new Map() + ) + const conversationPersistenceQueueRef = + useRef>(Promise.resolve()) + const conversationPersistencePausedRef = useRef(false) + const deletingLocalConversationIdsRef = useRef(new Set()) + const flushConversationPersistenceAfterRenderRef = useRef(false) const [unreadConversationIds, setUnreadConversationIds] = useState< Set >(() => new Set()) @@ -1509,6 +1668,9 @@ function App(): React.JSX.Element { useState() const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const deferredSearchQuery = useDeferredValue(searchQuery) + const [searchConversationSnapshot, setSearchConversationSnapshot] = + useState(conversations) const [conversationActionsId, setConversationActionsId] = useState('') const [confirmingConversationId, setConfirmingConversationId] = useState('') @@ -1591,6 +1753,7 @@ function App(): React.JSX.Element { const [activityRecords, setActivityRecords] = useState( loadActivityRecords ) + const activityRecordsRef = useRef(activityRecords) const activeRuns = useRef(new Map()) const preparingConversations = useRef(new Set()) const hydratingArtifactIds = useRef(new Set()) @@ -1599,6 +1762,17 @@ function App(): React.JSX.Element { const scrollRef = useRef(null) const chatPinnedToBottomRef = useRef(true) const chatScrollContextRef = useRef(`${view}:${activeId}`) + const prependScrollPositionRef = useRef<{ + conversationId: string + scrollHeight: number + scrollTop: number + } | undefined>(undefined) + const finalRevealedMessageIdRef = useRef(undefined) + const messageArticleRefs = useRef(new Map()) + const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({ + conversationId: activeId, + count: messageRenderBatchSize + })) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) @@ -1678,10 +1852,21 @@ function App(): React.JSX.Element { } }, [closeNarrowSidebar, narrowWindow, sidebarOpen]) - useEffect(() => { + useLayoutEffect(() => { conversationsRef.current = conversations }, [conversations]) + useEffect(() => { + if (!searchQuery.trim()) { + return + } + const timeout = window.setTimeout( + () => setSearchConversationSnapshot(conversations), + conversationSearchSnapshotDelayMs + ) + return () => window.clearTimeout(timeout) + }, [conversations, searchQuery]) + useEffect(() => { projectsRef.current = projects }, [projects]) @@ -1801,6 +1986,70 @@ function App(): React.JSX.Element { () => conversations.find((conversation) => conversation.id === activeId), [activeId, conversations] ) + const visibleMessageCount = + visibleMessageWindow.conversationId === activeId + ? visibleMessageWindow.count + : messageRenderBatchSize + const visibleMessageStartIndex = Math.max( + 0, + (activeConversation?.messages.length ?? 0) - visibleMessageCount + ) + const visibleMessages = + activeConversation?.messages.slice(visibleMessageStartIndex) ?? [] + const hiddenMessageCount = visibleMessageStartIndex + + const revealEarlierMessages = useCallback((): void => { + const scrollContainer = scrollRef.current + if (scrollContainer) { + prependScrollPositionRef.current = { + conversationId: activeId, + scrollHeight: scrollContainer.scrollHeight, + scrollTop: scrollContainer.scrollTop + } + } + const currentCount = + visibleMessageWindow.conversationId === activeId + ? visibleMessageWindow.count + : messageRenderBatchSize + if ( + activeConversation && + currentCount + messageRenderBatchSize >= + activeConversation.messages.length + ) { + finalRevealedMessageIdRef.current = + activeConversation.messages[0]?.id + } + setVisibleMessageWindow({ + conversationId: activeId, + count: currentCount + messageRenderBatchSize + }) + }, [activeConversation, activeId, visibleMessageWindow]) + + useLayoutEffect(() => { + const previous = prependScrollPositionRef.current + if (!previous) { + return + } + prependScrollPositionRef.current = undefined + if (previous.conversationId !== activeId) { + return + } + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + scrollContainer.scrollTop = + previous.scrollTop + + (scrollContainer.scrollHeight - previous.scrollHeight) + const finalRevealedMessageId = finalRevealedMessageIdRef.current + finalRevealedMessageIdRef.current = undefined + if (finalRevealedMessageId) { + messageArticleRefs.current + .get(finalRevealedMessageId) + ?.focus({ preventScroll: true }) + } + }, [activeId, visibleMessageWindow]) + const activeRuntimeSelection = useMemo( () => activeConversation?.runtimeSelection ?? @@ -2050,8 +2299,11 @@ function App(): React.JSX.Element { [activeProjectId, projects] ) const filteredConversations = useMemo(() => { - const query = searchQuery.trim().toLocaleLowerCase() - return conversations.filter( + const query = deferredSearchQuery.trim().toLocaleLowerCase() + const candidates = query + ? searchConversationSnapshot + : conversations + return candidates.filter( (conversation) => (!activeProjectId || conversation.projectId === activeProjectId) && @@ -2063,7 +2315,13 @@ function App(): React.JSX.Element { message.content.toLocaleLowerCase().includes(query) )) ) - }, [activeProject, activeProjectId, conversations, searchQuery]) + }, [ + activeProject, + activeProjectId, + conversations, + deferredSearchQuery, + searchConversationSnapshot + ]) const pendingSidebarApprovals = useMemo( () => conversations.flatMap((conversation) => @@ -2866,6 +3124,7 @@ function App(): React.JSX.Element { } }) activeRuns.current.delete(event.requestId) + flushConversationPersistenceAfterRenderRef.current = true } }, [ @@ -2892,25 +3151,86 @@ function App(): React.JSX.Element { viewRef.current = view }, [view]) + const persistLocalConversationChanges = useCallback((): void => { + const operation = conversationPersistenceQueueRef.current.then( + async () => { + if (conversationPersistencePausedRef.current) { + return + } + const { batch, acknowledgements } = + createLocalConversationSaveBatch( + conversationsRef.current, + persistedLocalConversationsRef.current, + deletingLocalConversationIdsRef.current + ) + if (batch.length === 0) { + return + } + await window.goodbuddy.conversations.saveLocal(batch) + for (const conversation of acknowledgements) { + persistedLocalConversationsRef.current.set( + conversation.id, + conversation + ) + } + } + ) + conversationPersistenceQueueRef.current = + operation.catch(() => undefined) + void operation.catch(() => { + notify({ + tone: 'error', + message: tRef.current( + 'notices.conversationPersistenceFailed' + ), + dedupeKey: 'conversation-persistence' + }) + }) + }, []) + useEffect(() => { if (!conversationStoreReady) { return } - const timeout = setTimeout(() => { - void window.goodbuddy.conversations - .replace(toConversationSnapshots(conversations)) - .catch(() => { - notify({ - tone: 'error', - message: tRef.current( - 'notices.conversationPersistenceFailed' - ), - dedupeKey: 'conversation-persistence' - }) - }) - }, 500) - return () => clearTimeout(timeout) - }, [conversationStoreReady, conversations]) + persistLocalConversationChanges() + const interval = window.setInterval( + persistLocalConversationChanges, + conversationPersistenceIntervalMs + ) + return () => { + window.clearInterval(interval) + persistLocalConversationChanges() + } + }, [conversationStoreReady, persistLocalConversationChanges]) + + useEffect(() => { + if ( + !conversationStoreReady || + !flushConversationPersistenceAfterRenderRef.current + ) { + return + } + flushConversationPersistenceAfterRenderRef.current = false + persistLocalConversationChanges() + }, [ + conversationStoreReady, + conversations, + persistLocalConversationChanges + ]) + + useEffect( + () => + window.goodbuddy.app.onBeforeQuit(async () => { + saveActivityRecords(activityRecordsRef.current) + if (!conversationStoreReady) { + return + } + flushConversationPersistenceAfterRenderRef.current = false + persistLocalConversationChanges() + await conversationPersistenceQueueRef.current + }), + [conversationStoreReady, persistLocalConversationChanges] + ) useEffect(() => { if (!conversationStoreReady) { @@ -2993,9 +3313,20 @@ function App(): React.JSX.Element { }, [conversationStoreReady]) useEffect(() => { - saveActivityRecords(activityRecords) + activityRecordsRef.current = activityRecords + const timeout = window.setTimeout(() => { + saveActivityRecords(activityRecords) + }, 250) + return () => window.clearTimeout(timeout) }, [activityRecords]) + useEffect( + () => () => { + saveActivityRecords(activityRecordsRef.current) + }, + [] + ) + useEffect(() => { let active = true void Promise.all([ @@ -3016,14 +3347,34 @@ function App(): React.JSX.Element { setWorkMode( normalizeInteractiveWorkMode(project.defaultWorkMode) ) - let nextConversations: Conversation[] = - persistedConversations.length > 0 - ? persistedConversations - : migrationConversations.current.map((conversation) => - conversation.projectId || project.kind === 'channel' - ? conversation - : { ...conversation, projectId: project.id } + const persistedLocalConversations = + persistedConversations.filter( + (conversation) => !conversation.remote ) + const persistedConversationIds = new Set( + persistedConversations.map((conversation) => conversation.id) + ) + const shouldMigrateLocalStorage = + conversationMigrationStoragePresent.current || + persistedConversations.length === 0 + const migratedLocalConversations = + shouldMigrateLocalStorage + ? migrationConversations.current + .filter( + (conversation) => + !conversation.remote && + !persistedConversationIds.has(conversation.id) + ) + .map((conversation) => + conversation.projectId || project.kind === 'channel' + ? conversation + : { ...conversation, projectId: project.id } + ) + : [] + let nextConversations: Conversation[] = [ + ...persistedConversations, + ...migratedLocalConversations + ] let projectConversation = nextConversations.find( (conversation) => conversation.projectId === project.id && @@ -3041,17 +3392,71 @@ function App(): React.JSX.Element { ...nextConversations ] } - if (persistedConversations.length === 0) { - await window.goodbuddy.conversations.replace( - toConversationSnapshots(nextConversations) + const acknowledgedLocalConversations = new Map( + persistedLocalConversations.map((conversation) => [ + conversation.id, + conversation + ]) + ) + if ( + nextConversations.some( + (conversation) => + !conversation.remote && + acknowledgedLocalConversations.get(conversation.id) !== + conversation ) + ) { + if (persistedConversations.length === 0) { + const migratedSnapshots = + toConversationSnapshots(nextConversations) + await window.goodbuddy.conversations.replace( + migratedSnapshots + ) + const migratedSnapshotIds = new Set( + migratedSnapshots.map((conversation) => conversation.id) + ) + for (const conversation of nextConversations) { + if (migratedSnapshotIds.has(conversation.id)) { + acknowledgedLocalConversations.set( + conversation.id, + conversation + ) + } + } + } + while (true) { + const migration = createLocalConversationSaveBatch( + nextConversations, + acknowledgedLocalConversations, + new Set() + ) + if (migration.batch.length === 0) { + break + } + await window.goodbuddy.conversations.saveLocal( + migration.batch + ) + for (const conversation of migration.acknowledgements) { + acknowledgedLocalConversations.set( + conversation.id, + conversation + ) + } + } } if (!active) { return } + persistedLocalConversationsRef.current = + acknowledgedLocalConversations setConversations(nextConversations) setActiveId(projectConversation?.id ?? '') - localStorage.removeItem(storageKey) + try { + localStorage.removeItem(storageKey) + conversationMigrationStoragePresent.current = false + } catch { + // The SQLite migration has already completed successfully. + } setConversationStoreReady(true) }) .catch((reason: unknown) => { @@ -3857,6 +4262,27 @@ function App(): React.JSX.Element { setDeletingConversationId('') return } + const deletingConversation = conversations.find( + (conversation) => conversation.id === conversationId + ) + if (deletingConversation && !deletingConversation.remote) { + deletingLocalConversationIdsRef.current.add(conversationId) + try { + await conversationPersistenceQueueRef.current + await window.goodbuddy.conversations.deleteLocal(conversationId) + persistedLocalConversationsRef.current.delete(conversationId) + } catch { + deletingLocalConversationIdsRef.current.delete(conversationId) + notify({ + tone: 'error', + message: t( + 'notices.deleteConversationPersistenceFailed' + ) + }) + setDeletingConversationId('') + return + } + } setConfirmingConversationId('') setDeletingConversationId('') if (conversationActionsId === conversationId) { @@ -3882,6 +4308,8 @@ function App(): React.JSX.Element { const remaining = conversations.filter( (conversation) => conversation.id !== conversationId ) + conversationsRef.current = remaining + deletingLocalConversationIdsRef.current.delete(conversationId) const projectRemaining = remaining.filter( (conversation) => conversation.projectId === activeProjectId ) @@ -4726,54 +5154,63 @@ function App(): React.JSX.Element { } const clearLocalData = async (): Promise => { - for (const requestId of activeRuns.current.keys()) { - await window.goodbuddy.agent.cancel(requestId) + conversationPersistencePausedRef.current = true + try { + await conversationPersistenceQueueRef.current + for (const requestId of activeRuns.current.keys()) { + await window.goodbuddy.agent.cancel(requestId) + } + activeRuns.current.clear() + for (const attachment of attachments) { + await window.goodbuddy.context.remove(attachment.id) + } + for (const library of knowledgeSnapshot.libraries) { + await window.goodbuddy.knowledge.deleteLibrary(library.id) + } + await window.goodbuddy.app.clearLocalData() + const conversation = createConversation( + activeProjectId || undefined, + runtimeSettings + ? getProjectDefaultRuntimeSelection( + activeProject, + runtimeSettings + ) + : undefined, + t('conversation.greeting') + ) + conversationsRef.current = [conversation] + persistedLocalConversationsRef.current.clear() + setConversations([conversation]) + setActiveId(conversation.id) + setActivityRecords([]) + setAssistantTasks([]) + setTokenUsage(emptyTokenUsage) + setAssistantArtifacts([]) + setAssistantMemories([]) + setAssistantSchedules([]) + setAssistantHeartbeats([]) + setHeartbeatEntries([]) + setHeartbeatRuns([]) + setKnowledgeSnapshot({ + libraries: [], + sources: [], + documents: [], + graphNodes: [], + graphRelations: [], + evidence: [] + }) + setEnabledKnowledgeLibraryIds([]) + updateAttachments([]) + setInput('') + setView('chat') + notify({ + tone: 'success', + message: t('notices.localDataCleared') + }) + } finally { + conversationPersistencePausedRef.current = false + persistLocalConversationChanges() } - activeRuns.current.clear() - for (const attachment of attachments) { - await window.goodbuddy.context.remove(attachment.id) - } - for (const library of knowledgeSnapshot.libraries) { - await window.goodbuddy.knowledge.deleteLibrary(library.id) - } - await window.goodbuddy.app.clearLocalData() - const conversation = createConversation( - activeProjectId || undefined, - runtimeSettings - ? getProjectDefaultRuntimeSelection( - activeProject, - runtimeSettings - ) - : undefined, - t('conversation.greeting') - ) - setConversations([conversation]) - setActiveId(conversation.id) - setActivityRecords([]) - setAssistantTasks([]) - setTokenUsage(emptyTokenUsage) - setAssistantArtifacts([]) - setAssistantMemories([]) - setAssistantSchedules([]) - setAssistantHeartbeats([]) - setHeartbeatEntries([]) - setHeartbeatRuns([]) - setKnowledgeSnapshot({ - libraries: [], - sources: [], - documents: [], - graphNodes: [], - graphRelations: [], - evidence: [] - }) - setEnabledKnowledgeLibraryIds([]) - updateAttachments([]) - setInput('') - setView('chat') - notify({ - tone: 'success', - message: t('notices.localDataCleared') - }) } const isRunning = @@ -5161,7 +5598,7 @@ function App(): React.JSX.Element { {filteredConversations.length === 0 && (

{activeProject?.kind === 'channel' && - !searchQuery.trim() + !deferredSearchQuery.trim() ? t('conversation.noRemote') : t('conversation.noMatches')}

@@ -5382,10 +5819,33 @@ function App(): React.JSX.Element { )}
- {activeConversation?.messages.map((message, messageIndex) => ( -
0 && ( + + )} + {activeConversation && + visibleMessages.map((message, visibleMessageIndex) => { + const messageIndex = + visibleMessageStartIndex + visibleMessageIndex + return ( +
{ + if (element) { + messageArticleRefs.current.set(message.id, element) + } else { + messageArticleRefs.current.delete(message.id) + } + }} + tabIndex={-1} >
{message.role === 'assistant' ? ( @@ -5931,8 +6391,9 @@ function App(): React.JSX.Element { )}
-
- ))} +
+ ) + })}
{showScrollToBottom && ( @@ -6624,11 +7085,41 @@ function App(): React.JSX.Element { ) : view === 'magic-notes' && magicNotesEnabled ? ( - + + } + > + + } + > + + + ) : view === 'knowledge' ? ( - + } + > + + } + > + + /> + + ) : view === 'heartbeat' ? ( - + } + > + + } + > + + /> + + ) : view === 'settings' ? ( - + } + > + + } + > + + /> + + ) : ( { ) }) + it('drops the oldest records until the stored JSON fits the load bound', () => { + const records = Array.from( + { length: MAX_ACTIVITY_RECORDS }, + (_, index) => ({ + ...makeRecord(index), + detail: 'x'.repeat(4_000) + }) + ) + + expect(saveActivityRecords(records)).toBe(true) + const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY) + expect(serialized?.length).toBeLessThanOrEqual(2_000_000) + const restored = loadActivityRecords() + expect(restored.length).toBeGreaterThan(0) + expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS) + expect(restored[0]?.id).toBe('activity-0') + }) + it('reports rejected writes without throwing', () => { const rejectingStorage = { setItem: () => { diff --git a/src/renderer/src/activity-store.ts b/src/renderer/src/activity-store.ts index 829333e..6871f5b 100644 --- a/src/renderer/src/activity-store.ts +++ b/src/renderer/src/activity-store.ts @@ -278,8 +278,9 @@ export function loadActivityRecords( } /** - * Persists at most 500 schema-valid records. Returns false if storage is - * unavailable or rejects the write. + * Persists as many of the newest schema-valid records as fit within the + * bounded storage payload. Returns false if storage is unavailable or rejects + * the write. */ export function saveActivityRecords( records: readonly ActivityRecord[], @@ -289,19 +290,32 @@ export function saveActivityRecords( return false } - const safeRecords: ActivityRecord[] = [] + const serializedRecords: string[] = [] + let serializedLength = 2 for (const record of records) { const safeRecord = parseActivityRecord(record) if (safeRecord) { - safeRecords.push(safeRecord) + const serializedRecord = JSON.stringify(safeRecord) + const nextLength = + serializedLength + + serializedRecord.length + + (serializedRecords.length > 0 ? 1 : 0) + if (nextLength > MAX_STORED_JSON_LENGTH) { + break + } + serializedRecords.push(serializedRecord) + serializedLength = nextLength } - if (safeRecords.length === MAX_ACTIVITY_RECORDS) { + if (serializedRecords.length === MAX_ACTIVITY_RECORDS) { break } } try { - storage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(safeRecords)) + storage.setItem( + ACTIVITY_STORAGE_KEY, + `[${serializedRecords.join(',')}]` + ) return true } catch { return false diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index d3decb3..dc7937f 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -49,6 +49,11 @@ export const app = { activity: 'Tasks & Activity', pendingSuggestions: '{{count}} pending suggestions' }, + route: { + loading: 'Loading page…', + loadFailed: 'The page component failed to load. Reload the app.', + reload: 'Reload' + }, sidebar: { label: 'Main sidebar', newConversation: 'New conversation', @@ -226,6 +231,7 @@ export const app = { } }, retry: 'Edit and send again', + loadEarlierMessages: 'Load earlier messages ({{count}} remaining)', scrollToBottom: 'Scroll to bottom', status: { responseTruncated: 'The response was too long and was truncated locally', @@ -419,6 +425,8 @@ export const app = { 'Channel conversations are created automatically when the client receives a new message', deleteConversationCancelFailed: 'Could not stop the running task, so the conversation was not deleted', + deleteConversationPersistenceFailed: + 'Could not delete the local conversation, so it was kept', deletedConversationBrowserCloseFailed: 'Failed to close the browser for the deleted conversation', conversationCopied: 'Conversation copied to the clipboard', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 1554876..2657f52 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -45,6 +45,11 @@ export const app = { activity: '任务与活动', pendingSuggestions: '{{count}} 条待处理建议' }, + route: { + loading: '正在加载页面…', + loadFailed: '页面组件加载失败,请重新加载应用', + reload: '重新加载' + }, sidebar: { label: '主侧栏', newConversation: '新建对话', @@ -219,6 +224,7 @@ export const app = { } }, retry: '重新编辑并发送', + loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)', scrollToBottom: '到底部', status: { responseTruncated: '回答过长,已在本地截断显示', @@ -395,6 +401,8 @@ export const app = { '通道项目的会话由客户端收到新消息后自动创建', deleteConversationCancelFailed: '停止会话中的运行任务失败,尚未删除对话', + deleteConversationPersistenceFailed: + '删除本地会话失败,已保留当前对话', deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败', conversationCopied: '对话已复制到剪贴板', clipboardUnavailable: '无法访问剪贴板,请检查系统权限', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index caeb86b..74333a4 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -3126,7 +3126,30 @@ button > svg { gap: 4px; } +.load-earlier-messages { + align-self: center; + min-height: var(--control-height); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-raised); + color: var(--text-secondary); + cursor: pointer; +} + +.load-earlier-messages:hover { + border-color: var(--accent-selected); + color: var(--text-primary); +} + +.load-earlier-messages:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .message { + content-visibility: auto; + contain-intrinsic-block-size: auto 160px; display: grid; min-width: 0; padding: 17px 8px; @@ -3138,6 +3161,56 @@ button > svg { grid-template-columns: minmax(0, 1fr) 30px; } +.route-loading-status { + display: flex; + min-height: min(360px, 50vh); + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--text-secondary); +} + +.route-loading-status > svg { + animation: route-loading-spin 1s linear infinite; +} + +.route-load-error { + display: flex; + min-height: min(360px, 50vh); + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-5); + gap: var(--space-3); + color: var(--text-secondary); + text-align: center; +} + +.route-load-error > svg { + color: var(--danger-solid); +} + +.route-load-error > button { + min-height: var(--control-height); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-raised); + color: var(--text-primary); + cursor: pointer; +} + +.route-load-error > button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@keyframes route-loading-spin { + to { + transform: rotate(360deg); + } +} + .message__avatar { display: grid; width: 29px; diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 927941f..131ed76 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -137,6 +137,83 @@ export type ConversationMessageBlock = z.infer< typeof conversationMessageBlockSchema > +export const conversationMessageSchema = z + .object({ + id: assistantIdSchema, + role: z.enum(['user', 'assistant']), + content: z.string().max(1_000_000), + reasoning: z.string().optional(), + blocks: conversationMessageBlocksSchema.optional(), + createdAt: z.number().int().nonnegative(), + state: z.enum(['streaming', 'complete', 'error']), + status: z.string().max(4_000).optional(), + tools: z.array(conversationToolActivitySchema).max(100).optional(), + sources: z.array(z.string().max(8_192)).max(100).optional(), + sourceReferences: z + .array( + z + .object({ + libraryId: assistantIdSchema, + libraryName: z.string().max(200), + documentId: assistantIdSchema, + chunkId: assistantIdSchema.optional(), + documentName: z.string().max(500), + sourceName: z.string().max(500), + sourceLocation: z.string().max(4_096).optional(), + locator: z.string().max(1_000).optional(), + snippet: z.string().max(16_000), + rank: z.number().finite(), + score: z.number().finite().optional(), + lexicalRank: z.number().int().positive().optional(), + vectorRank: z.number().int().positive().optional(), + graphRank: z.number().int().positive().optional(), + similarity: z.number().min(-1).max(1).optional(), + retrievalChannels: z + .array(z.enum(['fts', 'cjk', 'vector', 'graph'])) + .max(4) + .optional(), + evidenceIds: z + .array(assistantIdSchema) + .max(100) + .optional() + }) + .strict() + ) + .max(20) + .optional(), + knowledgeRetrieval: z + .object({ + mode: z.literal('always'), + state: z.enum([ + 'searching', + 'succeeded', + 'zero', + 'degraded', + 'failed', + 'cancelled' + ]), + libraryCount: z.number().int().min(1).max(20), + resultCount: z.number().int().nonnegative().max(20), + durationMs: z.number().int().nonnegative().optional(), + usedChannels: z + .array(z.enum(['fts', 'cjk', 'vector', 'graph'])) + .max(4), + warnings: z.array(z.string().max(500)).max(20) + }) + .strict() + .optional(), + artifactIds: z.array(assistantIdSchema).max(8).optional(), + attachments: z + .array(conversationAttachmentSchema) + .max(8) + .optional() + }) + .strict() + +export type ConversationMessage = z.infer< + typeof conversationMessageSchema +> + export const conversationSnapshotSchema = z .object({ id: assistantIdSchema, @@ -154,80 +231,7 @@ export const conversationSnapshotSchema = z title: z.string().trim().min(1).max(200), updatedAt: z.number().int().nonnegative(), messages: z - .array( - z - .object({ - id: assistantIdSchema, - role: z.enum(['user', 'assistant']), - content: z.string().max(1_000_000), - reasoning: z.string().optional(), - blocks: conversationMessageBlocksSchema.optional(), - createdAt: z.number().int().nonnegative(), - state: z.enum(['streaming', 'complete', 'error']), - status: z.string().max(4_000).optional(), - tools: z.array(conversationToolActivitySchema).max(100).optional(), - sources: z.array(z.string().max(8_192)).max(100).optional(), - sourceReferences: z - .array( - z - .object({ - libraryId: assistantIdSchema, - libraryName: z.string().max(200), - documentId: assistantIdSchema, - chunkId: assistantIdSchema.optional(), - documentName: z.string().max(500), - sourceName: z.string().max(500), - sourceLocation: z.string().max(4_096).optional(), - locator: z.string().max(1_000).optional(), - snippet: z.string().max(16_000), - rank: z.number().finite(), - score: z.number().finite().optional(), - lexicalRank: z.number().int().positive().optional(), - vectorRank: z.number().int().positive().optional(), - graphRank: z.number().int().positive().optional(), - similarity: z.number().min(-1).max(1).optional(), - retrievalChannels: z - .array(z.enum(['fts', 'cjk', 'vector', 'graph'])) - .max(4) - .optional(), - evidenceIds: z - .array(assistantIdSchema) - .max(100) - .optional() - }) - .strict() - ) - .max(20) - .optional(), - knowledgeRetrieval: z - .object({ - mode: z.literal('always'), - state: z.enum([ - 'searching', - 'succeeded', - 'zero', - 'degraded', - 'failed', - 'cancelled' - ]), - libraryCount: z.number().int().min(1).max(20), - resultCount: z.number().int().nonnegative().max(20), - durationMs: z.number().int().nonnegative().optional(), - usedChannels: z - .array(z.enum(['fts', 'cjk', 'vector', 'graph'])) - .max(4), - warnings: z.array(z.string().max(500)).max(20) - }) - .strict() - .optional(), - artifactIds: z.array(assistantIdSchema).max(8).optional(), - attachments: z - .array(conversationAttachmentSchema) - .max(8) - .optional() - }) - .strict() - ) + .array(conversationMessageSchema) .max(500) }) .strict() @@ -239,6 +243,35 @@ export const conversationSnapshotsSchema = z .array(conversationSnapshotSchema) .max(100) +export const localConversationHeaderSchema = conversationSnapshotSchema + .omit({ + messages: true, + remote: true + }) + +export type LocalConversationHeader = z.infer< + typeof localConversationHeaderSchema +> + +export const localConversationSaveSchema = z + .object({ + header: localConversationHeaderSchema, + messages: z.array(conversationMessageSchema).max(500) + }) + .strict() + +export type LocalConversationSaveInput = z.infer< + typeof localConversationSaveSchema +> + +export const localConversationSaveBatchSchema = z + .array(localConversationSaveSchema) + .max(100) + +export type LocalConversationSaveBatch = z.infer< + typeof localConversationSaveBatchSchema +> + export type AssistantProject = ProjectCreateInput & { id: string kind: ProjectKind diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 186c946..dc1942d 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -26,6 +26,7 @@ import { type TokenUsageSummary, type ConversationSnapshot, type ConversationAttachment, + type LocalConversationSaveBatch, type WorkspaceChanges, type WorkspaceDirectoryListing, type WorkspaceFilePreview, @@ -1112,6 +1113,7 @@ export type DesktopApi = { close: () => Promise isMaximized: () => Promise onMaximizedChanged: (listener: (maximized: boolean) => void) => () => void + onBeforeQuit: (listener: () => Promise) => () => void clearLocalData: () => Promise onNewConversation: (listener: () => void) => () => void onOpenSettings: (listener: () => void) => () => void @@ -1260,6 +1262,8 @@ export type DesktopApi = { conversations: { list: () => Promise replace: (conversations: ConversationSnapshot[]) => Promise + saveLocal: (batch: LocalConversationSaveBatch) => Promise + deleteLocal: (conversationId: string) => Promise onChanged: (listener: () => void) => () => void } workspace: { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index f139515..55ca804 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -7,6 +7,9 @@ export const ipcChannels = { windowClose: 'window:close', windowIsMaximized: 'window:is-maximized', windowMaximizedChanged: 'window:maximized-changed', + appRendererPersistenceReady: 'app:renderer-persistence-ready', + appRendererPersistenceRequest: 'app:renderer-persistence-request', + appRendererPersistenceComplete: 'app:renderer-persistence-complete', appClearLocalData: 'app:clear-local-data', conversationNew: 'conversation:new', settingsOpen: 'settings:open', @@ -83,6 +86,8 @@ export const ipcChannels = { projectsDelete: 'projects:delete', conversationsList: 'conversations:list', conversationsReplace: 'conversations:replace', + conversationsSaveLocal: 'conversations:save-local', + conversationsDeleteLocal: 'conversations:delete-local', conversationsChanged: 'conversations:changed', workspaceChangesGet: 'workspace:changes:get', workspaceDirectoryList: 'workspace:directory:list', diff --git a/tests/performance-regression.manual.test.ts b/tests/performance-regression.manual.test.ts new file mode 100644 index 0000000..06098ed --- /dev/null +++ b/tests/performance-regression.manual.test.ts @@ -0,0 +1,191 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + conversationSnapshotsSchema, + localConversationSaveBatchSchema, + type ConversationSnapshot, + type LocalConversationHeader, + type LocalConversationSaveBatch +} from '../src/shared/assistant-contracts' +import { AssistantDatabase } from '../src/main/assistant/assistant-database' + +const runPerformanceBenchmarks = process.env.GOODBUDDY_PERF === '1' +const conversationCount = 60 +const messagesPerConversation = 200 + +let temporaryDirectory = '' + +function benchmarkId(value: number): string { + return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` +} + +function createConversations( + projectId: string +): ConversationSnapshot[] { + return Array.from({ length: conversationCount }, (_, conversationIndex) => ({ + id: benchmarkId(conversationIndex + 1), + projectId, + title: `Conversation ${conversationIndex}`, + updatedAt: Date.UTC(2026, 7, 14, 12, conversationIndex), + messages: Array.from( + { length: messagesPerConversation }, + (_, messageIndex) => ({ + id: benchmarkId( + 1_000_000 + + conversationIndex * messagesPerConversation + + messageIndex + ), + role: messageIndex % 2 === 0 ? 'user' as const : 'assistant' as const, + content: `Message ${messageIndex} ${'x'.repeat(180)}`, + createdAt: Date.UTC( + 2026, + 7, + 14, + 12, + conversationIndex, + messageIndex + ), + state: 'complete' as const + }) + ) + })) +} + +function measure(operation: () => void): number { + const startedAt = performance.now() + operation() + return performance.now() - startedAt +} + +function localHeader( + conversation: ConversationSnapshot +): LocalConversationHeader { + return { + id: conversation.id, + projectId: conversation.projectId, + runtimeSelection: conversation.runtimeSelection, + knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, + title: conversation.title, + updatedAt: conversation.updatedAt + } +} + +describe.skipIf(!runPerformanceBenchmarks)( + 'manual performance regression benchmarks', + () => { + beforeAll(async () => { + temporaryDirectory = await mkdtemp( + join(tmpdir(), 'goodbuddy-performance-') + ) + }) + + afterAll(async () => { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }) + } + }) + + it('measures conversation persistence and hydration at scale', async () => { + const databasePath = join(temporaryDirectory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const conversations = conversationSnapshotsSchema.parse( + createConversations(project.id) + ) + + const initialReplaceMs = measure(() => { + database.replaceConversations(conversations) + }) + const unchangedReplaceMs = measure(() => { + database.replaceConversations(conversations) + }) + const updated = conversations.map((conversation, index) => + index === 0 + ? { + ...conversation, + updatedAt: conversation.updatedAt + 1, + messages: conversation.messages.map((message, messageIndex) => + messageIndex === conversation.messages.length - 1 + ? { ...message, content: `${message.content} updated` } + : message + ) + } + : conversation + ) + const legacySingleConversationUpdateMs = measure(() => { + database.replaceConversations(updated) + }) + const incrementallyUpdated = { + ...updated[0]!, + updatedAt: updated[0]!.updatedAt + 1, + messages: updated[0]!.messages.map((message, messageIndex) => + messageIndex === updated[0]!.messages.length - 1 + ? { ...message, content: `${message.content} incremental` } + : message + ) + } + const incrementalBatch: LocalConversationSaveBatch = + localConversationSaveBatchSchema.parse([ + { + header: localHeader(incrementallyUpdated), + messages: [incrementallyUpdated.messages.at(-1)!] + } + ]) + const incrementalSingleMessageMs = measure(() => { + database.saveLocalConversations(incrementalBatch) + }) + const metadataOnlyBatch: LocalConversationSaveBatch = [ + { + header: { + ...localHeader(incrementallyUpdated), + title: 'Incrementally renamed conversation', + updatedAt: incrementallyUpdated.updatedAt + 1 + }, + messages: [] + } + ] + const incrementalMetadataOnlyMs = measure(() => { + database.saveLocalConversations(metadataOnlyBatch) + }) + let restored: ConversationSnapshot[] = [] + const listMs = measure(() => { + restored = database.listConversations() + }) + database.close() + + const databaseBytes = (await stat(databasePath)).size + const metrics = { + conversationCount, + messagesPerConversation, + totalMessages: conversationCount * messagesPerConversation, + initialReplaceMs: Number(initialReplaceMs.toFixed(2)), + unchangedReplaceMs: Number(unchangedReplaceMs.toFixed(2)), + legacySingleConversationUpdateMs: Number( + legacySingleConversationUpdateMs.toFixed(2) + ), + incrementalSingleMessageMs: Number( + incrementalSingleMessageMs.toFixed(2) + ), + incrementalMetadataOnlyMs: Number( + incrementalMetadataOnlyMs.toFixed(2) + ), + fullSnapshotPayloadBytes: Buffer.byteLength( + JSON.stringify(conversations) + ), + incrementalPayloadBytes: Buffer.byteLength( + JSON.stringify(incrementalBatch) + ), + listMs: Number(listMs.toFixed(2)), + databaseBytes + } + console.log(`PERF_METRICS=${JSON.stringify(metrics)}`) + + expect(restored).toHaveLength(conversationCount) + expect(restored[0]?.messages).toHaveLength(messagesPerConversation) + }, 30_000) + } +)