feat: improve application responsiveness
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
+179
-48
@@ -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(
|
assistantDatabase.appendTaskEvent(
|
||||||
request.requestId,
|
request.requestId,
|
||||||
publicEvent.type,
|
publicEvent.type,
|
||||||
publicEvent
|
publicEvent
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
eventBuffer.push(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(ipcChannels.agentEvent, publicEvent)
|
window.webContents.send(
|
||||||
|
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 = {
|
|
||||||
requestId: request.requestId,
|
|
||||||
type: 'error',
|
|
||||||
status: controller.signal.aborted ? 'cancelled' : 'failed',
|
|
||||||
message: errorMessage
|
|
||||||
}
|
|
||||||
if (!persistedRuntimeError) {
|
|
||||||
assistantDatabase.appendTaskEvent(
|
assistantDatabase.appendTaskEvent(
|
||||||
request.requestId,
|
request.requestId,
|
||||||
agentEvent.type,
|
agentEvent.type,
|
||||||
agentEvent
|
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()),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+698
-39
@@ -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({
|
||||||
|
header: expect.objectContaining({
|
||||||
title: '第二模型对话',
|
title: '第二模型对话',
|
||||||
runtimeSelection: {
|
runtimeSelection: {
|
||||||
provider: 'model',
|
provider: 'model',
|
||||||
profileId: secondProfileId
|
profileId: secondProfileId
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
messages: expect.any(Array)
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
@@ -4855,7 +5512,9 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '新建笔记' })
|
screen.getByRole('button', { name: '新建笔记' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
await waitFor(() =>
|
||||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
screen.queryByLabelText('切换助手工作栏')
|
screen.queryByLabelText('切换助手工作栏')
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
|
|||||||
+557
-32
@@ -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,15 +891,24 @@ 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,
|
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) => ({
|
messages: conversation.messages
|
||||||
|
.slice(-500)
|
||||||
|
.map(toConversationMessage)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConversationMessage(message: Message): ConversationMessage {
|
||||||
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
@@ -826,8 +923,59 @@ function toConversationSnapshots(
|
|||||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||||
artifactIds: message.artifactIds,
|
artifactIds: message.artifactIds,
|
||||||
attachments: message.attachments
|
attachments: message.attachments
|
||||||
}))
|
}
|
||||||
}))
|
}
|
||||||
|
|
||||||
|
function toLocalConversationHeader(
|
||||||
|
conversation: Conversation
|
||||||
|
): LocalConversationHeader {
|
||||||
|
return {
|
||||||
|
id: conversation.id,
|
||||||
|
projectId: conversation.projectId,
|
||||||
|
runtimeSelection: conversation.runtimeSelection,
|
||||||
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
title: conversation.title,
|
||||||
|
updatedAt: conversation.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalConversationSaveBatch(
|
||||||
|
conversations: readonly Conversation[],
|
||||||
|
persisted: ReadonlyMap<string, Conversation>,
|
||||||
|
deletingConversationIds: ReadonlySet<string>
|
||||||
|
): {
|
||||||
|
batch: LocalConversationSaveBatch
|
||||||
|
acknowledgements: Conversation[]
|
||||||
|
} {
|
||||||
|
const batch: LocalConversationSaveBatch = []
|
||||||
|
const acknowledgements: Conversation[] = []
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
if (
|
||||||
|
conversation.remote ||
|
||||||
|
deletingConversationIds.has(conversation.id) ||
|
||||||
|
persisted.get(conversation.id) === conversation
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const previous = persisted.get(conversation.id)
|
||||||
|
const previousMessages = new Map(
|
||||||
|
previous?.messages.map((message) => [message.id, message]) ?? []
|
||||||
|
)
|
||||||
|
batch.push({
|
||||||
|
header: toLocalConversationHeader(conversation),
|
||||||
|
messages: conversation.messages
|
||||||
|
.filter(
|
||||||
|
(message) => previousMessages.get(message.id) !== message
|
||||||
|
)
|
||||||
|
.slice(-500)
|
||||||
|
.map(toConversationMessage)
|
||||||
|
})
|
||||||
|
acknowledgements.push(conversation)
|
||||||
|
if (batch.length === 100) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { batch, acknowledgements }
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeArtifacts(
|
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,14 +3151,33 @@ function App(): React.JSX.Element {
|
|||||||
viewRef.current = view
|
viewRef.current = view
|
||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
useEffect(() => {
|
const persistLocalConversationChanges = useCallback((): void => {
|
||||||
if (!conversationStoreReady) {
|
const operation = conversationPersistenceQueueRef.current.then(
|
||||||
|
async () => {
|
||||||
|
if (conversationPersistencePausedRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const timeout = setTimeout(() => {
|
const { batch, acknowledgements } =
|
||||||
void window.goodbuddy.conversations
|
createLocalConversationSaveBatch(
|
||||||
.replace(toConversationSnapshots(conversations))
|
conversationsRef.current,
|
||||||
.catch(() => {
|
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({
|
notify({
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
message: tRef.current(
|
message: tRef.current(
|
||||||
@@ -2908,9 +3186,51 @@ function App(): React.JSX.Element {
|
|||||||
dedupeKey: 'conversation-persistence'
|
dedupeKey: 'conversation-persistence'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, 500)
|
}, [])
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [conversationStoreReady, conversations])
|
useEffect(() => {
|
||||||
|
if (!conversationStoreReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
persistLocalConversationChanges()
|
||||||
|
const interval = window.setInterval(
|
||||||
|
persistLocalConversationChanges,
|
||||||
|
conversationPersistenceIntervalMs
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval)
|
||||||
|
persistLocalConversationChanges()
|
||||||
|
}
|
||||||
|
}, [conversationStoreReady, persistLocalConversationChanges])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!conversationStoreReady ||
|
||||||
|
!flushConversationPersistenceAfterRenderRef.current
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushConversationPersistenceAfterRenderRef.current = false
|
||||||
|
persistLocalConversationChanges()
|
||||||
|
}, [
|
||||||
|
conversationStoreReady,
|
||||||
|
conversations,
|
||||||
|
persistLocalConversationChanges
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
window.goodbuddy.app.onBeforeQuit(async () => {
|
||||||
|
saveActivityRecords(activityRecordsRef.current)
|
||||||
|
if (!conversationStoreReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushConversationPersistenceAfterRenderRef.current = false
|
||||||
|
persistLocalConversationChanges()
|
||||||
|
await conversationPersistenceQueueRef.current
|
||||||
|
}),
|
||||||
|
[conversationStoreReady, persistLocalConversationChanges]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationStoreReady) {
|
if (!conversationStoreReady) {
|
||||||
@@ -2993,9 +3313,20 @@ function App(): React.JSX.Element {
|
|||||||
}, [conversationStoreReady])
|
}, [conversationStoreReady])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
activityRecordsRef.current = activityRecords
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
saveActivityRecords(activityRecords)
|
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) =>
|
)
|
||||||
|
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.projectId || project.kind === 'channel'
|
||||||
? conversation
|
? conversation
|
||||||
: { ...conversation, projectId: project.id }
|
: { ...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 ?? '')
|
||||||
|
try {
|
||||||
localStorage.removeItem(storageKey)
|
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,6 +5154,9 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearLocalData = async (): Promise<void> => {
|
const clearLocalData = async (): Promise<void> => {
|
||||||
|
conversationPersistencePausedRef.current = true
|
||||||
|
try {
|
||||||
|
await conversationPersistenceQueueRef.current
|
||||||
for (const requestId of activeRuns.current.keys()) {
|
for (const requestId of activeRuns.current.keys()) {
|
||||||
await window.goodbuddy.agent.cancel(requestId)
|
await window.goodbuddy.agent.cancel(requestId)
|
||||||
}
|
}
|
||||||
@@ -4747,6 +5178,8 @@ function App(): React.JSX.Element {
|
|||||||
: undefined,
|
: undefined,
|
||||||
t('conversation.greeting')
|
t('conversation.greeting')
|
||||||
)
|
)
|
||||||
|
conversationsRef.current = [conversation]
|
||||||
|
persistedLocalConversationsRef.current.clear()
|
||||||
setConversations([conversation])
|
setConversations([conversation])
|
||||||
setActiveId(conversation.id)
|
setActiveId(conversation.id)
|
||||||
setActivityRecords([])
|
setActivityRecords([])
|
||||||
@@ -4774,6 +5207,10 @@ function App(): React.JSX.Element {
|
|||||||
tone: 'success',
|
tone: 'success',
|
||||||
message: t('notices.localDataCleared')
|
message: t('notices.localDataCleared')
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
conversationPersistencePausedRef.current = false
|
||||||
|
persistLocalConversationChanges()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 && (
|
||||||
|
<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
|
<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' ? (
|
||||||
@@ -5932,7 +6392,8 @@ function App(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{showScrollToBottom && (
|
{showScrollToBottom && (
|
||||||
@@ -6624,10 +7085,40 @@ function App(): React.JSX.Element {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
) : view === 'magic-notes' && magicNotesEnabled ? (
|
) : view === 'magic-notes' && magicNotesEnabled ? (
|
||||||
<PageShell variant="master-detail">
|
<PageShell variant="master-detail">
|
||||||
|
<RouteErrorBoundary
|
||||||
|
key="magic-notes"
|
||||||
|
fallback={
|
||||||
|
<RouteLoadError
|
||||||
|
message={t('route.loadFailed')}
|
||||||
|
reloadLabel={t('route.reload')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<RouteLoadingStatus label={t('route.loading')} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<MagicNotesWorkspace onNotify={notify} />
|
<MagicNotesWorkspace onNotify={notify} />
|
||||||
|
</Suspense>
|
||||||
|
</RouteErrorBoundary>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
) : view === 'knowledge' ? (
|
) : view === 'knowledge' ? (
|
||||||
<PageShell variant="master-detail">
|
<PageShell variant="master-detail">
|
||||||
|
<RouteErrorBoundary
|
||||||
|
key="knowledge"
|
||||||
|
fallback={
|
||||||
|
<RouteLoadError
|
||||||
|
message={t('route.loadFailed')}
|
||||||
|
reloadLabel={t('route.reload')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<RouteLoadingStatus label={t('route.loading')} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<KnowledgeWorkspace
|
<KnowledgeWorkspace
|
||||||
documents={knowledgeSnapshot.documents}
|
documents={knowledgeSnapshot.documents}
|
||||||
evidence={knowledgeSnapshot.evidence}
|
evidence={knowledgeSnapshot.evidence}
|
||||||
@@ -6909,9 +7400,25 @@ function App(): React.JSX.Element {
|
|||||||
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">
|
||||||
|
<RouteErrorBoundary
|
||||||
|
key="heartbeat"
|
||||||
|
fallback={
|
||||||
|
<RouteLoadError
|
||||||
|
message={t('route.loadFailed')}
|
||||||
|
reloadLabel={t('route.reload')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<RouteLoadingStatus label={t('route.loading')} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<HeartbeatCenter
|
<HeartbeatCenter
|
||||||
configs={assistantHeartbeats}
|
configs={assistantHeartbeats}
|
||||||
currentProjectName={activeProject?.name}
|
currentProjectName={activeProject?.name}
|
||||||
@@ -6931,8 +7438,24 @@ function App(): React.JSX.Element {
|
|||||||
runs={heartbeatRuns}
|
runs={heartbeatRuns}
|
||||||
tasks={assistantTasks}
|
tasks={assistantTasks}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</RouteErrorBoundary>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
) : view === 'settings' ? (
|
) : view === 'settings' ? (
|
||||||
|
<RouteErrorBoundary
|
||||||
|
key="settings"
|
||||||
|
fallback={
|
||||||
|
<RouteLoadError
|
||||||
|
message={t('route.loadFailed')}
|
||||||
|
reloadLabel={t('route.reload')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<RouteLoadingStatus label={t('route.loading')} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
appearanceTheme={appearanceTheme}
|
appearanceTheme={appearanceTheme}
|
||||||
heartbeats={assistantHeartbeats}
|
heartbeats={assistantHeartbeats}
|
||||||
@@ -6973,6 +7496,8 @@ function App(): React.JSX.Element {
|
|||||||
open
|
open
|
||||||
presentation="page"
|
presentation="page"
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</RouteErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
<PageShell variant="dashboard">
|
<PageShell variant="dashboard">
|
||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
|
serializedRecords.push(serializedRecord)
|
||||||
|
serializedLength = nextLength
|
||||||
|
}
|
||||||
|
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: '无法访问剪贴板,请检查系统权限',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -137,25 +137,7 @@ export type ConversationMessageBlock = z.infer<
|
|||||||
typeof conversationMessageBlockSchema
|
typeof conversationMessageBlockSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
export const conversationSnapshotSchema = z
|
export const conversationMessageSchema = z
|
||||||
.object({
|
|
||||||
id: assistantIdSchema,
|
|
||||||
projectId: assistantIdSchema.optional(),
|
|
||||||
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
|
||||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
|
||||||
remote: z
|
|
||||||
.object({
|
|
||||||
channel: projectChannelSchema,
|
|
||||||
accountDisplay: z.string().trim().min(1).max(200),
|
|
||||||
conversationType: z.enum(['direct', 'group'])
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.optional(),
|
|
||||||
title: z.string().trim().min(1).max(200),
|
|
||||||
updatedAt: z.number().int().nonnegative(),
|
|
||||||
messages: z
|
|
||||||
.array(
|
|
||||||
z
|
|
||||||
.object({
|
.object({
|
||||||
id: assistantIdSchema,
|
id: assistantIdSchema,
|
||||||
role: z.enum(['user', 'assistant']),
|
role: z.enum(['user', 'assistant']),
|
||||||
@@ -227,7 +209,29 @@ export const conversationSnapshotSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
)
|
|
||||||
|
export type ConversationMessage = z.infer<
|
||||||
|
typeof conversationMessageSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
export const conversationSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
id: assistantIdSchema,
|
||||||
|
projectId: assistantIdSchema.optional(),
|
||||||
|
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
||||||
|
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||||
|
remote: z
|
||||||
|
.object({
|
||||||
|
channel: projectChannelSchema,
|
||||||
|
accountDisplay: z.string().trim().min(1).max(200),
|
||||||
|
conversationType: z.enum(['direct', 'group'])
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
updatedAt: z.number().int().nonnegative(),
|
||||||
|
messages: z
|
||||||
|
.array(conversationMessageSchema)
|
||||||
.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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user