feat: improve application responsiveness

This commit is contained in:
mesalogo
2026-08-15 18:06:02 +08:00
parent 1329251b5a
commit 79e2511f6f
18 changed files with 3268 additions and 355 deletions
+177
View File
@@ -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)
})
})
+125
View File
@@ -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<typeof setTimeout> | 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
}
}
@@ -1386,6 +1386,429 @@ describe('AssistantDatabase', () => {
database.close() 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 () => { it('repairs unattended channel selections without rebinding ordinary conversations', async () => {
const database = await createDatabase() const database = await createDatabase()
const removedProfileId = const removedProfileId =
+269 -56
View File
@@ -14,6 +14,7 @@ import type {
AssistantProject, AssistantProject,
AssistantSchedule, AssistantSchedule,
AssistantTask, AssistantTask,
ConversationMessage,
ConversationSnapshot, ConversationSnapshot,
ExpertCreateInput, ExpertCreateInput,
ExpertUpdateInput, ExpertUpdateInput,
@@ -21,6 +22,7 @@ import type {
HeartbeatSummaryOutput, HeartbeatSummaryOutput,
HeartbeatUpdateInput, HeartbeatUpdateInput,
LegacyWorkMode, LegacyWorkMode,
LocalConversationSaveBatch,
MemoryCreateInput, MemoryCreateInput,
ModelUsageCallInput, ModelUsageCallInput,
ProjectChannel, 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 { export class AssistantDatabase {
private database?: DatabaseSync private database?: DatabaseSync
private channelEventWrites = 0 private channelEventWrites = 0
@@ -1279,69 +1355,45 @@ export class AssistantDatabase {
) )
ORDER BY sequence ASC` ORDER BY sequence ASC`
) )
return conversations.map((conversation) => ({ return conversations.map((conversation) =>
id: conversation.id, toConversationSnapshot(
projectId: conversation.project_id ?? undefined, conversation,
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: (
messageStatement.all(conversation.id) as MessageRow[] 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 { getConversation(conversationId: string): ConversationSnapshot {
const conversation = this.listConversations().find( const database = this.requireDatabase()
(candidate) => candidate.id === conversationId 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) { if (!conversation) {
throw new Error('对话不存在') 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( 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: { getOrCreateRemoteConversation(input: {
projectId: string projectId: string
channel: ProjectChannel channel: ProjectChannel
+271 -1
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels' import { ipcChannels } from '../shared/ipc-channels'
import type { AssistantProject } from '../shared/assistant-contracts' 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 { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database' import { AssistantDatabase } from './assistant/assistant-database'
import { registerIpcHandlers } from './ipc' 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', () => { describe('registerIpcHandlers agent terminal state', () => {
afterEach(() => { afterEach(() => {
electronMocks.handlers.clear() electronMocks.handlers.clear()
@@ -2258,6 +2429,105 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() 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<AgentEvent, { type: 'text' }> =>
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 () => { it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111' const libraryId = '11111111-1111-4111-8111-111111111111'
const documentId = '33333333-3333-4333-8333-333333333333' const documentId = '33333333-3333-4333-8333-333333333333'
+189 -58
View File
@@ -131,6 +131,7 @@ import {
import { import {
assistantIdSchema, assistantIdSchema,
conversationSnapshotsSchema, conversationSnapshotsSchema,
localConversationSaveBatchSchema,
memoryCreateSchema, memoryCreateSchema,
normalizeInteractiveWorkMode, normalizeInteractiveWorkMode,
projectChannelLabels, projectChannelLabels,
@@ -241,6 +242,7 @@ import {
analyzeMagicNoteEntry, analyzeMagicNoteEntry,
analyzeMagicTodo analyzeMagicTodo
} from './magic-notes/magic-note-analyzer' } from './magic-notes/magic-note-analyzer'
import { AgentEventBuffer } from './agent-event-buffer'
const requestIdSchema = z.string().uuid() const requestIdSchema = z.string().uuid()
const GOODBUDDY_RELEASES_URL = const GOODBUDDY_RELEASES_URL =
@@ -832,6 +834,7 @@ export function registerIpcHandlers(
goodbuddyConfigService?: GoodBuddyConfigService goodbuddyConfigService?: GoodBuddyConfigService
): () => Promise<void> { ): () => Promise<void> {
const activeRequests = new Map<string, AbortController>() const activeRequests = new Map<string, AbortController>()
const activeEventBuffers = new Map<string, { flush(): void }>()
const pendingAgentQuestions = new Map< const pendingAgentQuestions = new Map<
string, string,
{ requestId: string; runtime: AgentRuntime } { requestId: string; runtime: AgentRuntime }
@@ -840,6 +843,8 @@ export function registerIpcHandlers(
let shuttingDown = false let shuttingDown = false
let executionPaused = false let executionPaused = false
let clearLocalDataOperation: Promise<void> | undefined let clearLocalDataOperation: Promise<void> | undefined
let rendererPersistenceReady = false
const pendingRendererPersistence = new Map<string, () => void>()
let pendingGoodBuddyConfigReload = false let pendingGoodBuddyConfigReload = false
let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve() let goodBuddyConfigReloadQueue: Promise<void> = Promise.resolve()
const executionTracker = createPromiseTracker() const executionTracker = createPromiseTracker()
@@ -902,6 +907,40 @@ export function registerIpcHandlers(
ipcMain.removeHandler(channel) ipcMain.removeHandler(channel)
} }
const requestRendererPersistence = async (): Promise<void> => {
if (
!rendererPersistenceReady ||
window.isDestroyed() ||
(typeof window.webContents.isDestroyed === 'function' &&
window.webContents.isDestroyed())
) {
return
}
const requestId = randomUUID()
const completion = new Promise<void>((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<void> => {
await Promise.allSettled([
executionTracker.drain(),
maintenanceTracker.drain()
])
}
const notifyMaximizedChanged = (): void => { const notifyMaximizedChanged = (): void => {
if (!window.isDestroyed()) { if (!window.isDestroyed()) {
window.webContents.send( window.webContents.send(
@@ -967,6 +1006,7 @@ export function registerIpcHandlers(
}, },
signal, signal,
(approvalEvent) => { (approvalEvent) => {
activeEventBuffers.get(event.requestId)?.flush()
if (!window.isDestroyed()) { if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.agentEvent, approvalEvent) window.webContents.send(ipcChannels.agentEvent, approvalEvent)
} }
@@ -1029,6 +1069,7 @@ export function registerIpcHandlers(
parentTaskId: string, parentTaskId: string,
event: Extract<AgentEvent, { type: 'subagent' }> event: Extract<AgentEvent, { type: 'subagent' }>
): void => { ): void => {
activeEventBuffers.get(parentTaskId)?.flush()
assistantDatabase.appendTaskEvent( assistantDatabase.appendTaskEvent(
parentTaskId, parentTaskId,
event.type, event.type,
@@ -1218,6 +1259,16 @@ export function registerIpcHandlers(
let knowledgeCapabilityToken: string | undefined let knowledgeCapabilityToken: string | undefined
const resultAttachments: ChannelMediaAttachment[] = [] const resultAttachments: ChannelMediaAttachment[] = []
const artifactIds: string[] = [] const artifactIds: string[] = []
const eventBuffer = new AgentEventBuffer({
onError: (error) => controller.abort(error),
onEvent: (event) => {
assistantDatabase.appendTaskEvent(
requestId,
event.type,
event
)
}
})
try { try {
const requestRuntime = const requestRuntime =
remoteContext?.runtime ?? remoteContext?.runtime ??
@@ -1296,6 +1347,7 @@ export function registerIpcHandlers(
}, },
controller.signal, controller.signal,
(approvalEvent) => { (approvalEvent) => {
eventBuffer.flush()
if (!window.isDestroyed()) { if (!window.isDestroyed()) {
window.webContents.send( window.webContents.send(
ipcChannels.agentEvent, ipcChannels.agentEvent,
@@ -1380,11 +1432,7 @@ export function registerIpcHandlers(
if (taskEvent.type === 'artifact') { if (taskEvent.type === 'artifact') {
artifactIds.push(taskEvent.artifactId) artifactIds.push(taskEvent.artifactId)
} }
assistantDatabase.appendTaskEvent( eventBuffer.push(taskEvent)
requestId,
taskEvent.type,
taskEvent
)
if (taskEvent.type === 'tool' && remoteContext) { if (taskEvent.type === 'tool' && remoteContext) {
publishRemoteActivity({ publishRemoteActivity({
requestId, requestId,
@@ -1474,6 +1522,7 @@ export function registerIpcHandlers(
...(artifactIds.length > 0 ? { artifactIds } : {}) ...(artifactIds.length > 0 ? { artifactIds } : {})
} }
} catch (error) { } catch (error) {
eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败') const message = safeRuntimeError(error, '定时任务执行失败')
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
requestId, requestId,
@@ -1492,6 +1541,7 @@ export function registerIpcHandlers(
}) })
return { status: 'failed', error: message } return { status: 'failed', error: message }
} finally { } finally {
eventBuffer.close()
externalSignal?.removeEventListener( externalSignal?.removeEventListener(
'abort', 'abort',
abortFromExternal 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) => { registerHandler(ipcChannels.appShow, (event) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
showWindow(window) showWindow(window)
@@ -2280,10 +2349,58 @@ export function registerIpcHandlers(
const execution = (async () => { const execution = (async () => {
let outputText = '' let outputText = ''
let completed = false let completed = false
let persistedRuntimeError = false let runtimeErrorEvent:
| Extract<AgentEvent, { type: 'error' }>
| undefined
let executionRequest = request let executionRequest = request
let preflightReferences: KnowledgeSearchReference[] = [] let preflightReferences: KnowledgeSearchReference[] = []
let referencesPublished = false 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< const toolStates = new Map<
string, string,
Extract<AgentEvent, { type: 'tool' }> Extract<AgentEvent, { type: 'tool' }>
@@ -2294,17 +2411,7 @@ export function registerIpcHandlers(
{ type: 'knowledge-retrieval' } { type: 'knowledge-retrieval' }
> >
): void => { ): void => {
assistantDatabase.appendTaskEvent( eventBuffer.push(retrievalEvent)
request.requestId,
retrievalEvent.type,
retrievalEvent
)
if (!window.isDestroyed()) {
window.webContents.send(
ipcChannels.agentEvent,
retrievalEvent
)
}
} }
const publishReferences = (): void => { const publishReferences = (): void => {
if (referencesPublished) { if (referencesPublished) {
@@ -2337,17 +2444,7 @@ export function registerIpcHandlers(
type: 'source-references', type: 'source-references',
references references
} }
assistantDatabase.appendTaskEvent( eventBuffer.push(referenceEvent)
request.requestId,
referenceEvent.type,
referenceEvent
)
if (!window.isDestroyed()) {
window.webContents.send(
ipcChannels.agentEvent,
referenceEvent
)
}
} }
try { try {
controller.signal.throwIfAborted() controller.signal.throwIfAborted()
@@ -2593,12 +2690,7 @@ export function registerIpcHandlers(
}) })
} }
if (publicEvent.type === 'error') { if (publicEvent.type === 'error') {
assistantDatabase.appendTaskEvent( runtimeErrorEvent = publicEvent
request.requestId,
publicEvent.type,
publicEvent
)
persistedRuntimeError = true
throw new Error(publicEvent.message) throw new Error(publicEvent.message)
} }
if (publicEvent.type === 'done') { if (publicEvent.type === 'done') {
@@ -2615,12 +2707,15 @@ export function registerIpcHandlers(
) )
} }
publishReferences() 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') { if (publicEvent.type === 'done') {
completed = true completed = true
if (outputText.trim()) { if (outputText.trim()) {
@@ -2641,9 +2736,12 @@ export function registerIpcHandlers(
title: 'GoodBuddy 任务已完成', title: 'GoodBuddy 任务已完成',
body: '任务结果已保存到成果工作栏。' body: '任务结果已保存到成果工作栏。'
}) })
} if (!window.isDestroyed()) {
if (!window.isDestroyed()) { window.webContents.send(
window.webContents.send(ipcChannels.agentEvent, publicEvent) ipcChannels.agentEvent,
publicEvent
)
}
} }
if (completed) { if (completed) {
break break
@@ -2654,27 +2752,31 @@ export function registerIpcHandlers(
} }
} catch (error) { } catch (error) {
publishReferences() publishReferences()
eventBuffer.flush()
const errorMessage = controller.signal.aborted const errorMessage = controller.signal.aborted
? '请求已取消' ? '请求已取消'
: safeRuntimeError(error, 'Agent Runtime 执行失败') : 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( assistantDatabase.updateTaskStatus(
request.requestId, request.requestId,
controller.signal.aborted ? 'cancelled' : 'failed', controller.signal.aborted ? 'cancelled' : 'failed',
errorMessage errorMessage
) )
const agentEvent: AgentEvent = { assistantDatabase.appendTaskEvent(
requestId: request.requestId, request.requestId,
type: 'error', agentEvent.type,
status: controller.signal.aborted ? 'cancelled' : 'failed', agentEvent
message: errorMessage )
}
if (!persistedRuntimeError) {
assistantDatabase.appendTaskEvent(
request.requestId,
agentEvent.type,
agentEvent
)
}
showDesktopNotificationWhenUnfocused(window, { showDesktopNotificationWhenUnfocused(window, {
title: controller.signal.aborted title: controller.signal.aborted
? 'GoodBuddy 任务已取消' ? 'GoodBuddy 任务已取消'
@@ -2685,6 +2787,8 @@ export function registerIpcHandlers(
window.webContents.send(ipcChannels.agentEvent, agentEvent) window.webContents.send(ipcChannels.agentEvent, agentEvent)
} }
} finally { } finally {
eventBuffer.close()
activeEventBuffers.delete(request.requestId)
for (const [questionId, pending] of pendingAgentQuestions) { for (const [questionId, pending] of pendingAgentQuestions) {
if (pending.requestId === request.requestId) { if (pending.requestId === request.requestId) {
pendingAgentQuestions.delete(questionId) 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( registerHandler(
ipcChannels.workspaceChangesGet, ipcChannels.workspaceChangesGet,
async (event, input: unknown) => { async (event, input: unknown) => {
@@ -5000,9 +5124,6 @@ export function registerIpcHandlers(
clearInterval(scheduleInterval) clearInterval(scheduleInterval)
window.removeListener('maximize', notifyMaximizedChanged) window.removeListener('maximize', notifyMaximizedChanged)
window.removeListener('unmaximize', notifyMaximizedChanged) window.removeListener('unmaximize', notifyMaximizedChanged)
for (const channel of channels) {
ipcMain.removeHandler(channel)
}
abortActiveRequests('应用正在退出') abortActiveRequests('应用正在退出')
for (const controller of heartbeatControllers) { for (const controller of heartbeatControllers) {
controller.abort(new Error('应用正在退出')) controller.abort(new Error('应用正在退出'))
@@ -5019,6 +5140,16 @@ export function registerIpcHandlers(
approvalBroker.clear() approvalBroker.clear()
goodbuddyConfigService?.clear() goodbuddyConfigService?.clear()
pendingGoodBuddyConfigReload = false 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 await goodBuddyConfigReloadQueue
const channelCleanup = Promise.allSettled([ const channelCleanup = Promise.allSettled([
...channelServices.map((service) => service.stop()), ...channelServices.map((service) => service.stop()),
+36
View File
@@ -43,6 +43,7 @@ import type {
AssistantTask, AssistantTask,
TokenUsageSummary, TokenUsageSummary,
ConversationSnapshot, ConversationSnapshot,
LocalConversationSaveBatch,
WorkspaceChanges, WorkspaceChanges,
WorkspaceDirectoryListing, WorkspaceDirectoryListing,
WorkspaceFilePreview, WorkspaceFilePreview,
@@ -146,6 +147,30 @@ const desktopApi: DesktopApi = {
handler 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 () => { clearLocalData: async () => {
await ipcRenderer.invoke(ipcChannels.appClearLocalData) await ipcRenderer.invoke(ipcChannels.appClearLocalData)
}, },
@@ -555,6 +580,17 @@ const desktopApi: DesktopApi = {
conversations conversations
) )
}, },
saveLocal: async (batch: LocalConversationSaveBatch) => {
await ipcRenderer.invoke(
ipcChannels.conversationsSaveLocal,
batch
)
},
deleteLocal: (conversationId: string) =>
ipcRenderer.invoke(
ipcChannels.conversationsDeleteLocal,
conversationId
) as Promise<boolean>,
onChanged: (listener) => { onChanged: (listener) => {
const handler = (): void => listener() const handler = (): void => listener()
ipcRenderer.on(ipcChannels.conversationsChanged, handler) ipcRenderer.on(ipcChannels.conversationsChanged, handler)
+705 -46
View File
@@ -20,6 +20,26 @@ const speechRecognitionMocks = vi.hoisted(() => ({
startPcmRecording: vi.fn() startPcmRecording: vi.fn()
})) }))
const lazyRouteMocks = vi.hoisted(() => {
let pending: Promise<void> | undefined
let releasePending: (() => void) | undefined
return {
suspendKnowledgeRoute(): void {
pending = new Promise((resolve) => {
releasePending = resolve
})
},
releaseKnowledgeRoute(): void {
releasePending?.()
releasePending = undefined
pending = undefined
},
async waitForKnowledgeRoute(): Promise<void> {
await pending
}
}
})
vi.mock('./speech-recognition', async (importOriginal) => ({ vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal< ...(await importOriginal<
typeof import('./speech-recognition') typeof import('./speech-recognition')
@@ -27,6 +47,11 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
startPcmRecording: speechRecognitionMocks.startPcmRecording startPcmRecording: speechRecognitionMocks.startPcmRecording
})) }))
vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
await lazyRouteMocks.waitForKnowledgeRoute()
return importOriginal<typeof import('./KnowledgeWorkspace')>()
})
import App from './App' import App from './App'
import { loadActivityRecords } from './activity-store' import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
@@ -41,6 +66,7 @@ let fileSelectionProgressListener:
| undefined | undefined
let newConversationListener: (() => void) | undefined let newConversationListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined let maximizedChangedListener: ((maximized: boolean) => void) | undefined
let beforeQuitListener: (() => Promise<void>) | undefined
const removeMaximizedChangedListener = vi.fn() const removeMaximizedChangedListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>() const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001' const modelProfileId = '00000000-0000-4000-8000-000000000001'
@@ -76,6 +102,12 @@ const api: DesktopApi = {
maximizedChangedListener = listener maximizedChangedListener = listener
return removeMaximizedChangedListener return removeMaximizedChangedListener
}), }),
onBeforeQuit: vi.fn((listener) => {
beforeQuitListener = listener
return () => {
beforeQuitListener = undefined
}
}),
clearLocalData: vi.fn(async () => {}), clearLocalData: vi.fn(async () => {}),
onNewConversation: vi.fn((listener) => { onNewConversation: vi.fn((listener) => {
newConversationListener = listener newConversationListener = listener
@@ -289,6 +321,8 @@ const api: DesktopApi = {
conversations: { conversations: {
list: vi.fn(async () => []), list: vi.fn(async () => []),
replace: vi.fn(async () => {}), replace: vi.fn(async () => {}),
saveLocal: vi.fn(async () => {}),
deleteLocal: vi.fn(async () => true),
onChanged: vi.fn(() => () => undefined) onChanged: vi.fn(() => () => undefined)
}, },
workspace: { workspace: {
@@ -664,7 +698,21 @@ describe('App', () => {
delete document.documentElement.dataset.theme delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = '' document.documentElement.style.colorScheme = ''
vi.clearAllMocks() 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 newConversationListener = undefined
beforeQuitListener = undefined
browserListener = undefined browserListener = undefined
fileSelectionProgressListener = undefined fileSelectionProgressListener = undefined
maximizedChangedListener = undefined maximizedChangedListener = undefined
@@ -774,6 +822,122 @@ describe('App', () => {
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument() expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
}) })
it('shows an accessible fallback while a lazy route loads', async () => {
lazyRouteMocks.suspendKnowledgeRoute()
try {
render(<App />)
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(<App />)
const search = await screen.findByLabelText('搜索对话')
const conversationList =
container.querySelector<HTMLElement>('.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 () => { it('keeps Settings open when the interface language changes', async () => {
api.updates = { api.updates = {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
@@ -1225,6 +1389,69 @@ describe('App', () => {
).not.toBeInTheDocument() ).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(<App />)
expect(await screen.findByText('历史消息 160')).toBeInTheDocument()
expect(screen.queryByText('历史消息 080')).not.toBeInTheDocument()
expect(container.querySelectorAll('.message')).toHaveLength(80)
const chat = container.querySelector<HTMLElement>('.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 () => { it('keeps the reader position while a response continues below', async () => {
render(<App />) render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
@@ -1335,6 +1562,68 @@ describe('App', () => {
}) })
).not.toBeInTheDocument() ).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(<App />)
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 () => { it('keeps a conversation when cancelling its active task fails', async () => {
@@ -1576,6 +1865,155 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge') 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(<App />)
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(<App />)
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 () => { it('replaces the direct-model thinking status with real reasoning', async () => {
render(<App />) render(<App />)
@@ -1773,9 +2211,9 @@ describe('App', () => {
await waitFor( await waitFor(
() => { () => {
const persistedMessages = vi const persistedMessages = vi
.mocked(api.conversations.replace) .mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([conversations]) => .mock.calls.flatMap(([batch]) =>
conversations.flatMap((conversation) => conversation.messages) batch.flatMap((conversation) => conversation.messages)
) )
const persisted = persistedMessages const persisted = persistedMessages
.filter((message) => message.role === 'assistant') .filter((message) => message.role === 'assistant')
@@ -1864,12 +2302,12 @@ describe('App', () => {
expect(screen.getByText('向量模型未配置')).toBeInTheDocument() expect(screen.getByText('向量模型未配置')).toBeInTheDocument()
await waitFor(() => { await waitFor(() => {
const snapshots = vi const snapshots = vi
.mocked(api.conversations.replace) .mocked(api.conversations.saveLocal)
.mock.calls.at(-1)?.[0] .mock.calls.flatMap(([batch]) => batch)
expect( expect(
snapshots?.some( snapshots?.some(
(conversation) => (conversation) =>
conversation.knowledgeRetrievalMode === 'always' conversation.header.knowledgeRetrievalMode === 'always'
) )
).toBe(true) ).toBe(true)
}) })
@@ -2007,7 +2445,7 @@ describe('App', () => {
expect(anchorClick).toHaveBeenCalledOnce() expect(anchorClick).toHaveBeenCalledOnce()
await waitFor( await waitFor(
() => () =>
expect(api.conversations.replace).toHaveBeenCalledWith( expect(api.conversations.saveLocal).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
messages: expect.arrayContaining([ messages: expect.arrayContaining([
@@ -2844,14 +3282,14 @@ describe('App', () => {
setTimeout(resolve, 550) setTimeout(resolve, 550)
}) })
) )
expect(api.conversations.replace).not.toHaveBeenCalledWith( const savedChannelHeaders = vi
expect.arrayContaining([ .mocked(api.conversations.saveLocal)
expect.objectContaining({ .mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header))
projectId: channelProject.id, expect(
remote: undefined savedChannelHeaders.some(
}) (header) => header.projectId === channelProject.id
]) )
) ).toBe(false)
expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0) expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '打开设置' })) 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(<App />)
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(<App />)
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(<App />)
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 () => { it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: '00000000-0000-4000-8000-000000000020', id: '00000000-0000-4000-8000-000000000020',
projectId,
runtimeSelection: { provider: 'auto' }, runtimeSelection: { provider: 'auto' },
title: '旧自动对话', title: '旧自动对话',
updatedAt: 1, updatedAt: 1,
@@ -3468,18 +4134,10 @@ describe('App', () => {
render(<App />) render(<App />)
expect( expect(
await screen.findByRole('button', { name: /.*sonnet-5/u }) await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument() ).toBeInTheDocument()
await waitFor(() => expect(api.conversations.replace).not.toHaveBeenCalled()
expect(api.conversations.replace).toHaveBeenLastCalledWith( expect(api.conversations.saveLocal).not.toHaveBeenCalled()
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: { provider: 'auto' }
})
])
)
)
}) })
it('keeps a removed model selection visible until the user replaces it', async () => { 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([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: '00000000-0000-4000-8000-000000000022', id: '00000000-0000-4000-8000-000000000022',
projectId,
runtimeSelection: { runtimeSelection: {
provider: 'model', provider: 'model',
profileId: removedProfileId profileId: removedProfileId
@@ -3507,19 +4166,14 @@ describe('App', () => {
]) ])
render(<App />) render(<App />)
await waitFor(() => expect(
expect(api.conversations.replace).toHaveBeenLastCalledWith( await screen.findByText('旧消息')
expect.arrayContaining([ ).toBeInTheDocument()
expect.objectContaining({ expect(
id: '00000000-0000-4000-8000-000000000022', screen.getByRole('button', { name: //u })
runtimeSelection: { ).toBeInTheDocument()
provider: 'model', expect(api.conversations.replace).not.toHaveBeenCalled()
profileId: removedProfileId expect(api.conversations.saveLocal).not.toHaveBeenCalled()
}
})
])
)
)
}) })
it('keeps model Runtime selection scoped to its conversation', async () => { it('keeps model Runtime selection scoped to its conversation', async () => {
@@ -3614,14 +4268,17 @@ describe('App', () => {
await waitFor( await waitFor(
() => () =>
expect(api.conversations.replace).toHaveBeenLastCalledWith( expect(api.conversations.saveLocal).toHaveBeenLastCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
title: '第二模型对话', header: expect.objectContaining({
runtimeSelection: { title: '第二模型对话',
provider: 'model', runtimeSelection: {
profileId: secondProfileId provider: 'model',
} profileId: secondProfileId
}
}),
messages: expect.any(Array)
}) })
]) ])
), ),
@@ -4855,7 +5512,9 @@ describe('App', () => {
expect( expect(
screen.getByRole('button', { name: '新建笔记' }) screen.getByRole('button', { name: '新建笔记' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(api.magicNotes.list).toHaveBeenCalled() await waitFor(() =>
expect(api.magicNotes.list).toHaveBeenCalled()
)
expect( expect(
screen.queryByLabelText('切换助手工作栏') screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument() ).not.toBeInTheDocument()
+639 -114
View File
@@ -38,8 +38,13 @@ import {
X X
} from 'lucide-react' } from 'lucide-react'
import { import {
Component,
lazy,
Suspense,
useCallback, useCallback,
useDeferredValue,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useReducer, useReducer,
useRef, useRef,
@@ -84,10 +89,13 @@ import type {
AssistantExpert, AssistantExpert,
AssistantTask, AssistantTask,
TokenUsageSummary, TokenUsageSummary,
ConversationMessage,
ConversationSnapshot, ConversationSnapshot,
ConversationAttachment, ConversationAttachment,
ConversationMessageBlock, ConversationMessageBlock,
ConversationToolActivity, ConversationToolActivity,
LocalConversationHeader,
LocalConversationSaveBatch,
ProjectCreateInput, ProjectCreateInput,
InteractiveWorkMode, InteractiveWorkMode,
ProjectChannel, ProjectChannel,
@@ -109,13 +117,10 @@ import {
upsertActivityRecord, upsertActivityRecord,
type ActivityRecord type ActivityRecord
} from './activity-store' } from './activity-store'
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
import { import {
KnowledgeCitationDialog, KnowledgeCitationDialog,
type KnowledgeCitationContextView type KnowledgeCitationContextView
} from './KnowledgeCitationDialog' } from './KnowledgeCitationDialog'
import { HeartbeatCenter } from './HeartbeatCenter'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
import { MarkdownRenderer } from './MarkdownRenderer' import { MarkdownRenderer } from './MarkdownRenderer'
import { import {
DestructiveConfirmActions, DestructiveConfirmActions,
@@ -133,7 +138,6 @@ import {
type PendingSidebarApproval, type PendingSidebarApproval,
type SidebarArtifact type SidebarArtifact
} from './RightAssistantSidebar' } from './RightAssistantSidebar'
import { SettingsPanel } from './SettingsPanel'
import type { SettingsCategoryId } from './settings-categories' import type { SettingsCategoryId } from './settings-categories'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png'
@@ -158,6 +162,30 @@ import type {
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog' 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 = { type AppNotification = {
id: string id: string
message: string message: string
@@ -206,6 +234,58 @@ function appNotificationReducer(
return [...errors, ...transient] return [...errors, ...transient]
} }
function RouteLoadingStatus({
label
}: {
label: string
}): React.JSX.Element {
return (
<div
aria-busy="true"
aria-label={label}
aria-live="polite"
className="route-loading-status"
role="status"
>
<LoaderCircle aria-hidden="true" size={20} />
<span>{label}</span>
</div>
)
}
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 (
<div className="route-load-error" role="alert">
<CircleAlert aria-hidden="true" size={20} />
<strong>{message}</strong>
<button onClick={() => window.location.reload()} type="button">
{reloadLabel}
</button>
</div>
)
}
function AppNotificationItem({ function AppNotificationItem({
notification, notification,
dispatch dispatch
@@ -735,6 +815,14 @@ function loadConversations(
} }
} }
function hasConversationMigrationStorage(): boolean {
try {
return localStorage.getItem(storageKey) !== null
} catch {
return false
}
}
function loadActiveProjectId(): string | undefined { function loadActiveProjectId(): string | undefined {
try { try {
return localStorage.getItem(activeProjectStorageKey) || undefined return localStorage.getItem(activeProjectStorageKey) || undefined
@@ -803,31 +891,91 @@ function isConversation(value: unknown): value is Conversation {
function toConversationSnapshots( function toConversationSnapshots(
conversations: Conversation[] conversations: Conversation[]
): ConversationSnapshot[] { ): 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, id: conversation.id,
projectId: conversation.projectId, projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection, runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
remote: conversation.remote,
title: conversation.title, title: conversation.title,
updatedAt: conversation.updatedAt, updatedAt: conversation.updatedAt
messages: conversation.messages.slice(-500).map((message) => ({ }
id: message.id, }
role: message.role,
content: message.content, function createLocalConversationSaveBatch(
reasoning: message.reasoning, conversations: readonly Conversation[],
blocks: message.blocks, persisted: ReadonlyMap<string, Conversation>,
createdAt: message.createdAt, deletingConversationIds: ReadonlySet<string>
state: message.state, ): {
status: message.status, batch: LocalConversationSaveBatch
tools: message.tools, acknowledgements: Conversation[]
sources: message.sources, } {
sourceReferences: message.sourceReferences, const batch: LocalConversationSaveBatch = []
knowledgeRetrieval: message.knowledgeRetrieval, const acknowledgements: Conversation[] = []
artifactIds: message.artifactIds, for (const conversation of conversations) {
attachments: message.attachments 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( function mergeArtifacts(
@@ -1307,6 +1455,9 @@ function App(): React.JSX.Element {
tRef.current = t tRef.current = t
}, [t]) }, [t])
const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN' const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN'
const conversationMigrationStoragePresent = useRef(
hasConversationMigrationStorage()
)
const [conversations, setConversations] = useState(() => const [conversations, setConversations] = useState(() =>
loadConversations( loadConversations(
t('conversation.greeting'), t('conversation.greeting'),
@@ -1316,6 +1467,14 @@ function App(): React.JSX.Element {
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '') const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
const activeConversationIdRef = useRef(activeId) const activeConversationIdRef = useRef(activeId)
const conversationsRef = useRef(conversations) const conversationsRef = useRef(conversations)
const persistedLocalConversationsRef = useRef(
new Map<string, Conversation>()
)
const conversationPersistenceQueueRef =
useRef<Promise<void>>(Promise.resolve())
const conversationPersistencePausedRef = useRef(false)
const deletingLocalConversationIdsRef = useRef(new Set<string>())
const flushConversationPersistenceAfterRenderRef = useRef(false)
const [unreadConversationIds, setUnreadConversationIds] = useState< const [unreadConversationIds, setUnreadConversationIds] = useState<
Set<string> Set<string>
>(() => new Set()) >(() => new Set())
@@ -1509,6 +1668,9 @@ function App(): React.JSX.Element {
useState<ProjectChannel>() useState<ProjectChannel>()
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const deferredSearchQuery = useDeferredValue(searchQuery)
const [searchConversationSnapshot, setSearchConversationSnapshot] =
useState(conversations)
const [conversationActionsId, setConversationActionsId] = useState('') const [conversationActionsId, setConversationActionsId] = useState('')
const [confirmingConversationId, setConfirmingConversationId] = const [confirmingConversationId, setConfirmingConversationId] =
useState('') useState('')
@@ -1591,6 +1753,7 @@ function App(): React.JSX.Element {
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>( const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords loadActivityRecords
) )
const activityRecordsRef = useRef(activityRecords)
const activeRuns = useRef(new Map<string, ActiveRun>()) const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>()) const preparingConversations = useRef(new Set<string>())
const hydratingArtifactIds = useRef(new Set<string>()) const hydratingArtifactIds = useRef(new Set<string>())
@@ -1599,6 +1762,17 @@ function App(): React.JSX.Element {
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const chatPinnedToBottomRef = useRef(true) const chatPinnedToBottomRef = useRef(true)
const chatScrollContextRef = useRef(`${view}:${activeId}`) const chatScrollContextRef = useRef(`${view}:${activeId}`)
const prependScrollPositionRef = useRef<{
conversationId: string
scrollHeight: number
scrollTop: number
} | undefined>(undefined)
const finalRevealedMessageIdRef = useRef<string | undefined>(undefined)
const messageArticleRefs = useRef(new Map<string, HTMLElement>())
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({
conversationId: activeId,
count: messageRenderBatchSize
}))
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null) const sidebarToggleRef = useRef<HTMLButtonElement>(null)
@@ -1678,10 +1852,21 @@ function App(): React.JSX.Element {
} }
}, [closeNarrowSidebar, narrowWindow, sidebarOpen]) }, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => { useLayoutEffect(() => {
conversationsRef.current = conversations conversationsRef.current = conversations
}, [conversations]) }, [conversations])
useEffect(() => {
if (!searchQuery.trim()) {
return
}
const timeout = window.setTimeout(
() => setSearchConversationSnapshot(conversations),
conversationSearchSnapshotDelayMs
)
return () => window.clearTimeout(timeout)
}, [conversations, searchQuery])
useEffect(() => { useEffect(() => {
projectsRef.current = projects projectsRef.current = projects
}, [projects]) }, [projects])
@@ -1801,6 +1986,70 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId), () => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations] [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( const activeRuntimeSelection = useMemo(
() => () =>
activeConversation?.runtimeSelection ?? activeConversation?.runtimeSelection ??
@@ -2050,8 +2299,11 @@ function App(): React.JSX.Element {
[activeProjectId, projects] [activeProjectId, projects]
) )
const filteredConversations = useMemo(() => { const filteredConversations = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase() const query = deferredSearchQuery.trim().toLocaleLowerCase()
return conversations.filter( const candidates = query
? searchConversationSnapshot
: conversations
return candidates.filter(
(conversation) => (conversation) =>
(!activeProjectId || (!activeProjectId ||
conversation.projectId === activeProjectId) && conversation.projectId === activeProjectId) &&
@@ -2063,7 +2315,13 @@ function App(): React.JSX.Element {
message.content.toLocaleLowerCase().includes(query) message.content.toLocaleLowerCase().includes(query)
)) ))
) )
}, [activeProject, activeProjectId, conversations, searchQuery]) }, [
activeProject,
activeProjectId,
conversations,
deferredSearchQuery,
searchConversationSnapshot
])
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>( const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() => () =>
conversations.flatMap((conversation) => conversations.flatMap((conversation) =>
@@ -2866,6 +3124,7 @@ function App(): React.JSX.Element {
} }
}) })
activeRuns.current.delete(event.requestId) activeRuns.current.delete(event.requestId)
flushConversationPersistenceAfterRenderRef.current = true
} }
}, },
[ [
@@ -2892,25 +3151,86 @@ function App(): React.JSX.Element {
viewRef.current = view viewRef.current = view
}, [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(() => { useEffect(() => {
if (!conversationStoreReady) { if (!conversationStoreReady) {
return return
} }
const timeout = setTimeout(() => { persistLocalConversationChanges()
void window.goodbuddy.conversations const interval = window.setInterval(
.replace(toConversationSnapshots(conversations)) persistLocalConversationChanges,
.catch(() => { conversationPersistenceIntervalMs
notify({ )
tone: 'error', return () => {
message: tRef.current( window.clearInterval(interval)
'notices.conversationPersistenceFailed' persistLocalConversationChanges()
), }
dedupeKey: 'conversation-persistence' }, [conversationStoreReady, persistLocalConversationChanges])
})
}) useEffect(() => {
}, 500) if (
return () => clearTimeout(timeout) !conversationStoreReady ||
}, [conversationStoreReady, conversations]) !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(() => { useEffect(() => {
if (!conversationStoreReady) { if (!conversationStoreReady) {
@@ -2993,9 +3313,20 @@ function App(): React.JSX.Element {
}, [conversationStoreReady]) }, [conversationStoreReady])
useEffect(() => { useEffect(() => {
saveActivityRecords(activityRecords) activityRecordsRef.current = activityRecords
const timeout = window.setTimeout(() => {
saveActivityRecords(activityRecords)
}, 250)
return () => window.clearTimeout(timeout)
}, [activityRecords]) }, [activityRecords])
useEffect(
() => () => {
saveActivityRecords(activityRecordsRef.current)
},
[]
)
useEffect(() => { useEffect(() => {
let active = true let active = true
void Promise.all([ void Promise.all([
@@ -3016,14 +3347,34 @@ function App(): React.JSX.Element {
setWorkMode( setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode) normalizeInteractiveWorkMode(project.defaultWorkMode)
) )
let nextConversations: Conversation[] = const persistedLocalConversations =
persistedConversations.length > 0 persistedConversations.filter(
? persistedConversations (conversation) => !conversation.remote
: migrationConversations.current.map((conversation) =>
conversation.projectId || project.kind === 'channel'
? conversation
: { ...conversation, projectId: project.id }
) )
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( let projectConversation = nextConversations.find(
(conversation) => (conversation) =>
conversation.projectId === project.id && conversation.projectId === project.id &&
@@ -3041,17 +3392,71 @@ function App(): React.JSX.Element {
...nextConversations ...nextConversations
] ]
} }
if (persistedConversations.length === 0) { const acknowledgedLocalConversations = new Map(
await window.goodbuddy.conversations.replace( persistedLocalConversations.map((conversation) => [
toConversationSnapshots(nextConversations) 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) { if (!active) {
return return
} }
persistedLocalConversationsRef.current =
acknowledgedLocalConversations
setConversations(nextConversations) setConversations(nextConversations)
setActiveId(projectConversation?.id ?? '') setActiveId(projectConversation?.id ?? '')
localStorage.removeItem(storageKey) try {
localStorage.removeItem(storageKey)
conversationMigrationStoragePresent.current = false
} catch {
// The SQLite migration has already completed successfully.
}
setConversationStoreReady(true) setConversationStoreReady(true)
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
@@ -3857,6 +4262,27 @@ function App(): React.JSX.Element {
setDeletingConversationId('') setDeletingConversationId('')
return 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('') setConfirmingConversationId('')
setDeletingConversationId('') setDeletingConversationId('')
if (conversationActionsId === conversationId) { if (conversationActionsId === conversationId) {
@@ -3882,6 +4308,8 @@ function App(): React.JSX.Element {
const remaining = conversations.filter( const remaining = conversations.filter(
(conversation) => conversation.id !== conversationId (conversation) => conversation.id !== conversationId
) )
conversationsRef.current = remaining
deletingLocalConversationIdsRef.current.delete(conversationId)
const projectRemaining = remaining.filter( const projectRemaining = remaining.filter(
(conversation) => conversation.projectId === activeProjectId (conversation) => conversation.projectId === activeProjectId
) )
@@ -4726,54 +5154,63 @@ function App(): React.JSX.Element {
} }
const clearLocalData = async (): Promise<void> => { const clearLocalData = async (): Promise<void> => {
for (const requestId of activeRuns.current.keys()) { conversationPersistencePausedRef.current = true
await window.goodbuddy.agent.cancel(requestId) 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 = const isRunning =
@@ -5161,7 +5598,7 @@ function App(): React.JSX.Element {
{filteredConversations.length === 0 && ( {filteredConversations.length === 0 && (
<p className="conversation-empty"> <p className="conversation-empty">
{activeProject?.kind === 'channel' && {activeProject?.kind === 'channel' &&
!searchQuery.trim() !deferredSearchQuery.trim()
? t('conversation.noRemote') ? t('conversation.noRemote')
: t('conversation.noMatches')} : t('conversation.noMatches')}
</p> </p>
@@ -5382,10 +5819,33 @@ function App(): React.JSX.Element {
)} )}
<div className="message-list"> <div className="message-list">
{activeConversation?.messages.map((message, messageIndex) => ( {hiddenMessageCount > 0 && (
<article <button
className="load-earlier-messages"
onClick={revealEarlierMessages}
type="button"
>
{t('chat.loadEarlierMessages', {
count: hiddenMessageCount
})}
</button>
)}
{activeConversation &&
visibleMessages.map((message, visibleMessageIndex) => {
const messageIndex =
visibleMessageStartIndex + visibleMessageIndex
return (
<article
className={`message message--${message.role}`} className={`message message--${message.role}`}
key={message.id} key={message.id}
ref={(element) => {
if (element) {
messageArticleRefs.current.set(message.id, element)
} else {
messageArticleRefs.current.delete(message.id)
}
}}
tabIndex={-1}
> >
<div className="message__avatar"> <div className="message__avatar">
{message.role === 'assistant' ? ( {message.role === 'assistant' ? (
@@ -5931,8 +6391,9 @@ function App(): React.JSX.Element {
</button> </button>
)} )}
</div> </div>
</article> </article>
))} )
})}
</div> </div>
</section> </section>
{showScrollToBottom && ( {showScrollToBottom && (
@@ -6624,11 +7085,41 @@ function App(): React.JSX.Element {
</PageShell> </PageShell>
) : view === 'magic-notes' && magicNotesEnabled ? ( ) : view === 'magic-notes' && magicNotesEnabled ? (
<PageShell variant="master-detail"> <PageShell variant="master-detail">
<MagicNotesWorkspace onNotify={notify} /> <RouteErrorBoundary
key="magic-notes"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<MagicNotesWorkspace onNotify={notify} />
</Suspense>
</RouteErrorBoundary>
</PageShell> </PageShell>
) : view === 'knowledge' ? ( ) : view === 'knowledge' ? (
<PageShell variant="master-detail"> <PageShell variant="master-detail">
<KnowledgeWorkspace <RouteErrorBoundary
key="knowledge"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<KnowledgeWorkspace
documents={knowledgeSnapshot.documents} documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence} evidence={knowledgeSnapshot.evidence}
graphNodes={knowledgeSnapshot.graphNodes} graphNodes={knowledgeSnapshot.graphNodes}
@@ -6908,11 +7399,27 @@ function App(): React.JSX.Element {
selectedLibraryId={knowledgeSnapshot.selectedLibraryId} selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources} sources={knowledgeSnapshot.sources}
tasks={knowledgeSnapshot.tasks} tasks={knowledgeSnapshot.tasks}
/> />
</Suspense>
</RouteErrorBoundary>
</PageShell> </PageShell>
) : view === 'heartbeat' ? ( ) : view === 'heartbeat' ? (
<PageShell variant="dashboard"> <PageShell variant="dashboard">
<HeartbeatCenter <RouteErrorBoundary
key="heartbeat"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<HeartbeatCenter
configs={assistantHeartbeats} configs={assistantHeartbeats}
currentProjectName={activeProject?.name} currentProjectName={activeProject?.name}
entries={heartbeatEntries} entries={heartbeatEntries}
@@ -6930,10 +7437,26 @@ function App(): React.JSX.Element {
onUseFollowUpTask={useHeartbeatTask} onUseFollowUpTask={useHeartbeatTask}
runs={heartbeatRuns} runs={heartbeatRuns}
tasks={assistantTasks} tasks={assistantTasks}
/> />
</Suspense>
</RouteErrorBoundary>
</PageShell> </PageShell>
) : view === 'settings' ? ( ) : view === 'settings' ? (
<SettingsPanel <RouteErrorBoundary
key="settings"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<SettingsPanel
appearanceTheme={appearanceTheme} appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats} heartbeats={assistantHeartbeats}
initialCategory={settingsInitialCategory} initialCategory={settingsInitialCategory}
@@ -6972,7 +7495,9 @@ function App(): React.JSX.Element {
onSetHeartbeatPaused={setHeartbeatPaused} onSetHeartbeatPaused={setHeartbeatPaused}
open open
presentation="page" presentation="page"
/> />
</Suspense>
</RouteErrorBoundary>
) : ( ) : (
<PageShell variant="dashboard"> <PageShell variant="dashboard">
<ActivityPanel <ActivityPanel
+18
View File
@@ -105,6 +105,24 @@ describe('activity-store', () => {
) )
}) })
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', () => { it('reports rejected writes without throwing', () => {
const rejectingStorage = { const rejectingStorage = {
setItem: () => { setItem: () => {
+20 -6
View File
@@ -278,8 +278,9 @@ export function loadActivityRecords(
} }
/** /**
* Persists at most 500 schema-valid records. Returns false if storage is * Persists as many of the newest schema-valid records as fit within the
* unavailable or rejects the write. * bounded storage payload. Returns false if storage is unavailable or rejects
* the write.
*/ */
export function saveActivityRecords( export function saveActivityRecords(
records: readonly ActivityRecord[], records: readonly ActivityRecord[],
@@ -289,19 +290,32 @@ export function saveActivityRecords(
return false return false
} }
const safeRecords: ActivityRecord[] = [] const serializedRecords: string[] = []
let serializedLength = 2
for (const record of records) { for (const record of records) {
const safeRecord = parseActivityRecord(record) const safeRecord = parseActivityRecord(record)
if (safeRecord) { 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 break
} }
} }
try { try {
storage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(safeRecords)) storage.setItem(
ACTIVITY_STORAGE_KEY,
`[${serializedRecords.join(',')}]`
)
return true return true
} catch { } catch {
return false return false
@@ -49,6 +49,11 @@ export const app = {
activity: 'Tasks & Activity', activity: 'Tasks & Activity',
pendingSuggestions: '{{count}} pending suggestions' pendingSuggestions: '{{count}} pending suggestions'
}, },
route: {
loading: 'Loading page…',
loadFailed: 'The page component failed to load. Reload the app.',
reload: 'Reload'
},
sidebar: { sidebar: {
label: 'Main sidebar', label: 'Main sidebar',
newConversation: 'New conversation', newConversation: 'New conversation',
@@ -226,6 +231,7 @@ export const app = {
} }
}, },
retry: 'Edit and send again', retry: 'Edit and send again',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom', scrollToBottom: 'Scroll to bottom',
status: { status: {
responseTruncated: 'The response was too long and was truncated locally', 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', 'Channel conversations are created automatically when the client receives a new message',
deleteConversationCancelFailed: deleteConversationCancelFailed:
'Could not stop the running task, so the conversation was not deleted', 'Could not stop the running task, so the conversation was not deleted',
deleteConversationPersistenceFailed:
'Could not delete the local conversation, so it was kept',
deletedConversationBrowserCloseFailed: deletedConversationBrowserCloseFailed:
'Failed to close the browser for the deleted conversation', 'Failed to close the browser for the deleted conversation',
conversationCopied: 'Conversation copied to the clipboard', conversationCopied: 'Conversation copied to the clipboard',
@@ -45,6 +45,11 @@ export const app = {
activity: '任务与活动', activity: '任务与活动',
pendingSuggestions: '{{count}} 条待处理建议' pendingSuggestions: '{{count}} 条待处理建议'
}, },
route: {
loading: '正在加载页面…',
loadFailed: '页面组件加载失败,请重新加载应用',
reload: '重新加载'
},
sidebar: { sidebar: {
label: '主侧栏', label: '主侧栏',
newConversation: '新建对话', newConversation: '新建对话',
@@ -219,6 +224,7 @@ export const app = {
} }
}, },
retry: '重新编辑并发送', retry: '重新编辑并发送',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部', scrollToBottom: '到底部',
status: { status: {
responseTruncated: '回答过长,已在本地截断显示', responseTruncated: '回答过长,已在本地截断显示',
@@ -395,6 +401,8 @@ export const app = {
'通道项目的会话由客户端收到新消息后自动创建', '通道项目的会话由客户端收到新消息后自动创建',
deleteConversationCancelFailed: deleteConversationCancelFailed:
'停止会话中的运行任务失败,尚未删除对话', '停止会话中的运行任务失败,尚未删除对话',
deleteConversationPersistenceFailed:
'删除本地会话失败,已保留当前对话',
deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败', deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败',
conversationCopied: '对话已复制到剪贴板', conversationCopied: '对话已复制到剪贴板',
clipboardUnavailable: '无法访问剪贴板,请检查系统权限', clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
+73
View File
@@ -3126,7 +3126,30 @@ button > svg {
gap: 4px; 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 { .message {
content-visibility: auto;
contain-intrinsic-block-size: auto 160px;
display: grid; display: grid;
min-width: 0; min-width: 0;
padding: 17px 8px; padding: 17px 8px;
@@ -3138,6 +3161,56 @@ button > svg {
grid-template-columns: minmax(0, 1fr) 30px; 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 { .message__avatar {
display: grid; display: grid;
width: 29px; width: 29px;
+107 -74
View File
@@ -137,6 +137,83 @@ export type ConversationMessageBlock = z.infer<
typeof conversationMessageBlockSchema 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 export const conversationSnapshotSchema = z
.object({ .object({
id: assistantIdSchema, id: assistantIdSchema,
@@ -154,80 +231,7 @@ export const conversationSnapshotSchema = z
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
updatedAt: z.number().int().nonnegative(), updatedAt: z.number().int().nonnegative(),
messages: z messages: z
.array( .array(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()
)
.max(500) .max(500)
}) })
.strict() .strict()
@@ -239,6 +243,35 @@ export const conversationSnapshotsSchema = z
.array(conversationSnapshotSchema) .array(conversationSnapshotSchema)
.max(100) .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 & { export type AssistantProject = ProjectCreateInput & {
id: string id: string
kind: ProjectKind kind: ProjectKind
+4
View File
@@ -26,6 +26,7 @@ import {
type TokenUsageSummary, type TokenUsageSummary,
type ConversationSnapshot, type ConversationSnapshot,
type ConversationAttachment, type ConversationAttachment,
type LocalConversationSaveBatch,
type WorkspaceChanges, type WorkspaceChanges,
type WorkspaceDirectoryListing, type WorkspaceDirectoryListing,
type WorkspaceFilePreview, type WorkspaceFilePreview,
@@ -1112,6 +1113,7 @@ export type DesktopApi = {
close: () => Promise<void> close: () => Promise<void>
isMaximized: () => Promise<boolean> isMaximized: () => Promise<boolean>
onMaximizedChanged: (listener: (maximized: boolean) => void) => () => void onMaximizedChanged: (listener: (maximized: boolean) => void) => () => void
onBeforeQuit: (listener: () => Promise<void>) => () => void
clearLocalData: () => Promise<void> clearLocalData: () => Promise<void>
onNewConversation: (listener: () => void) => () => void onNewConversation: (listener: () => void) => () => void
onOpenSettings: (listener: () => void) => () => void onOpenSettings: (listener: () => void) => () => void
@@ -1260,6 +1262,8 @@ export type DesktopApi = {
conversations: { conversations: {
list: () => Promise<ConversationSnapshot[]> list: () => Promise<ConversationSnapshot[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void> replace: (conversations: ConversationSnapshot[]) => Promise<void>
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
deleteLocal: (conversationId: string) => Promise<boolean>
onChanged: (listener: () => void) => () => void onChanged: (listener: () => void) => () => void
} }
workspace: { workspace: {
+5
View File
@@ -7,6 +7,9 @@ export const ipcChannels = {
windowClose: 'window:close', windowClose: 'window:close',
windowIsMaximized: 'window:is-maximized', windowIsMaximized: 'window:is-maximized',
windowMaximizedChanged: 'window:maximized-changed', windowMaximizedChanged: 'window:maximized-changed',
appRendererPersistenceReady: 'app:renderer-persistence-ready',
appRendererPersistenceRequest: 'app:renderer-persistence-request',
appRendererPersistenceComplete: 'app:renderer-persistence-complete',
appClearLocalData: 'app:clear-local-data', appClearLocalData: 'app:clear-local-data',
conversationNew: 'conversation:new', conversationNew: 'conversation:new',
settingsOpen: 'settings:open', settingsOpen: 'settings:open',
@@ -83,6 +86,8 @@ export const ipcChannels = {
projectsDelete: 'projects:delete', projectsDelete: 'projects:delete',
conversationsList: 'conversations:list', conversationsList: 'conversations:list',
conversationsReplace: 'conversations:replace', conversationsReplace: 'conversations:replace',
conversationsSaveLocal: 'conversations:save-local',
conversationsDeleteLocal: 'conversations:delete-local',
conversationsChanged: 'conversations:changed', conversationsChanged: 'conversations:changed',
workspaceChangesGet: 'workspace:changes:get', workspaceChangesGet: 'workspace:changes:get',
workspaceDirectoryList: 'workspace:directory:list', workspaceDirectoryList: 'workspace:directory:list',
+191
View File
@@ -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)
}
)