fix: keep chat replies out of results

Completed local and channel replies were duplicated into Results as Markdown artifacts, making the panel a second conversation history. Replies now stay in their conversations, legacy duplicates are filtered without deleting data, and standalone images, scheduled jobs, headless delegations, heartbeats, and imports remain visible.

Release note: 普通本地与消息通道回复不再重复出现在成果栏;已有重复内容仅从列表隐藏,不会删除历史数据。
This commit is contained in:
mesalogo
2026-08-18 13:14:13 +08:00
parent e387c09f52
commit 0413807b8f
10 changed files with 173 additions and 84 deletions
+93 -13
View File
@@ -974,19 +974,6 @@ describe('AssistantDatabase', () => {
status: 'completed', status: 'completed',
completedAt: expect.any(String) completedAt: expect.any(String)
}) })
const artifact = database.createTextArtifact({
projectId: project.id,
taskId,
title: '发布说明',
content: '# 发布说明\n\n内容'
})
expect(database.listArtifacts(project.id)).toEqual([
expect.objectContaining({
id: artifact.id,
kind: 'markdown',
content: '# 发布说明\n\n内容'
})
])
const memory = database.createMemory({ const memory = database.createMemory({
scope: 'project', scope: 'project',
scopeId: project.id, scopeId: project.id,
@@ -2205,6 +2192,99 @@ describe('AssistantDatabase', () => {
durable.close() durable.close()
}) })
it('keeps duplicate chat replies out of artifact listings', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
const chatTaskId = '00000000-0000-4000-8000-000000000231'
const channelTaskId = '00000000-0000-4000-8000-000000000232'
const scheduleTaskId = '00000000-0000-4000-8000-000000000233'
const delegationTaskId = '00000000-0000-4000-8000-000000000234'
database.createTask({
id: chatTaskId,
projectId: project.id,
conversationId: 'conversation-chat',
title: '普通对话',
instructions: '回答问题',
workMode: 'ask'
})
database.createTask({
id: channelTaskId,
projectId: project.id,
conversationId: 'conversation-channel',
title: '远程对话',
instructions: '回答远程消息',
workMode: 'ask',
origin: 'delegation'
})
database.createTask({
id: scheduleTaskId,
projectId: project.id,
conversationId: 'schedule:daily',
title: '每日报告',
instructions: '生成报告',
workMode: 'ask',
origin: 'schedule'
})
database.createTask({
id: delegationTaskId,
projectId: project.id,
conversationId: 'delegation:weekly-report',
title: '委派报告',
instructions: '生成远程委派报告',
workMode: 'ask',
origin: 'delegation'
})
const chatReply = database.createTextArtifact({
projectId: project.id,
taskId: chatTaskId,
title: '普通对话',
content: '普通回复'
})
const channelReply = database.createTextArtifact({
projectId: project.id,
taskId: channelTaskId,
title: '远程对话',
content: '远程回复'
})
const scheduledReport = database.createTextArtifact({
projectId: project.id,
taskId: scheduleTaskId,
title: '每日报告',
content: '# 每日报告'
})
const delegatedReport = database.createTextArtifact({
projectId: project.id,
taskId: delegationTaskId,
title: '委派报告',
content: '# 委派报告'
})
const generatedImage = database.createImageArtifact({
projectId: project.id,
taskId: chatTaskId,
title: '生成图片',
mimeType: 'image/png',
base64: 'iVBORw0KGgo='
})
const visibleArtifactIds = database
.listArtifacts(project.id)
.map((artifact) => artifact.id)
expect(visibleArtifactIds).toEqual(
expect.arrayContaining([
scheduledReport.id,
delegatedReport.id,
generatedImage.id
])
)
expect(visibleArtifactIds).not.toContain(chatReply.id)
expect(visibleArtifactIds).not.toContain(channelReply.id)
expect(database.getArtifact(chatReply.id)).toMatchObject({
id: chatReply.id,
content: '普通回复'
})
database.close()
})
it('loads image artifact content only when requested by id', async () => { it('loads image artifact content only when requested by id', async () => {
const database = await createDatabase() const database = await createDatabase()
const artifact = database.createInlineArtifact({ const artifact = database.createInlineArtifact({
+16 -1
View File
@@ -3166,11 +3166,25 @@ export class AssistantDatabase {
CASE WHEN kind = 'image' THEN NULL CASE WHEN kind = 'image' THEN NULL
ELSE inline_content END AS inline_content, ELSE inline_content END AS inline_content,
byte_size, created_at, updated_at` byte_size, created_at, updated_at`
const visibleArtifact = `NOT (
kind = 'markdown' AND EXISTS (
SELECT 1
FROM tasks
WHERE tasks.id = artifacts.task_id
AND tasks.conversation_id IS NOT NULL
AND (
tasks.origin = 'user' OR (
tasks.origin = 'delegation'
AND tasks.conversation_id NOT LIKE 'delegation:%'
)
)
)
)`
const rows = projectId const rows = projectId
? this.requireDatabase() ? this.requireDatabase()
.prepare( .prepare(
`SELECT ${columns} FROM artifacts `SELECT ${columns} FROM artifacts
WHERE project_id = ? WHERE project_id = ? AND ${visibleArtifact}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?` LIMIT ?`
) )
@@ -3178,6 +3192,7 @@ export class AssistantDatabase {
: this.requireDatabase() : this.requireDatabase()
.prepare( .prepare(
`SELECT ${columns} FROM artifacts `SELECT ${columns} FROM artifacts
WHERE ${visibleArtifact}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?` LIMIT ?`
) )
+4 -6
View File
@@ -3213,12 +3213,7 @@ describe('registerIpcHandlers agent terminal state', () => {
).toBe(expectedOutput) ).toBe(expectedOutput)
expect( expect(
harness.assistantDatabase.createTextArtifact harness.assistantDatabase.createTextArtifact
).toHaveBeenCalledWith( ).not.toHaveBeenCalled()
expect.objectContaining({
taskId: requestId,
content: expectedOutput
})
)
await harness.dispose() await harness.dispose()
}) })
@@ -4663,6 +4658,9 @@ describe('registerIpcHandlers agent terminal state', () => {
'base64' 'base64'
).toString('utf8') ).toString('utf8')
).toBe('# 本周报告\n\n已完成。') ).toBe('# 本周报告\n\n已完成。')
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
await harness.dispose() await harness.dispose()
}) })
+5 -23
View File
@@ -1551,7 +1551,7 @@ export function registerIpcHandlers(
}) })
} }
} }
if (output.trim()) { if (origin !== 'channel' && output.trim()) {
assistantDatabase.createTextArtifact({ assistantDatabase.createTextArtifact({
projectId: schedule.projectId, projectId: schedule.projectId,
taskId: requestId, taskId: requestId,
@@ -1568,7 +1568,9 @@ export function registerIpcHandlers(
body: body:
origin === 'channel' origin === 'channel'
? '结果已回复,并保存到远程通道会话。' ? '结果已回复,并保存到远程通道会话。'
: '结果已保存到 GoodBuddy 成果工作栏。' : origin === 'schedule'
? '结果已保存到 GoodBuddy 成果工作栏。'
: '结果已保存到成果工作栏和委派记录。'
}) })
return { return {
status: 'completed', status: 'completed',
@@ -2413,7 +2415,6 @@ export function registerIpcHandlers(
activeRequests.set(request.requestId, controller) activeRequests.set(request.requestId, controller)
const execution = (async () => { const execution = (async () => {
let outputText = ''
let completed = false let completed = false
let runtimeErrorEvent: let runtimeErrorEvent:
| Extract<AgentEvent, { type: 'error' }> | Extract<AgentEvent, { type: 'error' }>
@@ -2784,15 +2785,6 @@ export function registerIpcHandlers(
.slice(0, 120) .slice(0, 120)
}) })
: agentEvent : agentEvent
if (
publicEvent.type === 'text' &&
outputText.length < 1_000_000
) {
outputText += publicEvent.delta.slice(
0,
1_000_000 - outputText.length
)
}
if (publicEvent.type === 'tool') { if (publicEvent.type === 'tool') {
toolStates.set(publicEvent.callId, publicEvent) toolStates.set(publicEvent.callId, publicEvent)
} }
@@ -2831,23 +2823,13 @@ export function registerIpcHandlers(
} }
if (publicEvent.type === 'done') { if (publicEvent.type === 'done') {
completed = true completed = true
if (outputText.trim()) {
assistantDatabase.createTextArtifact({
projectId: request.projectId,
taskId: request.requestId,
title: parsedRequest.prompt
.split(/\r?\n/, 1)[0]!
.slice(0, 120),
content: outputText
})
}
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
request.requestId, request.requestId,
'completed' 'completed'
) )
showDesktopNotificationWhenUnfocused(window, { showDesktopNotificationWhenUnfocused(window, {
title: 'GoodBuddy 任务已完成', title: 'GoodBuddy 任务已完成',
body: '任务结果已保存到成果工作栏。' body: '回复已生成,可返回会话查看。'
}) })
if (!window.isDestroyed()) { if (!window.isDestroyed()) {
window.webContents.send( window.webContents.send(
+42 -2
View File
@@ -6640,9 +6640,9 @@ describe('App', () => {
screen.getByText(/Agent 打开网页后/) screen.getByText(/Agent 打开网页后/)
).toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '成果' })) fireEvent.click(screen.getByRole('tab', { name: '成果' }))
expect(screen.getByText('对话与导入成果')).toBeInTheDocument() expect(screen.getByText('生成与导入成果')).toBeInTheDocument()
expect( expect(
screen.getByText(/查看并预览由对话生成或手动导入/) screen.getByText(/查看并预览由任务、自动化生成或手动导入/)
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.queryByRole('tab', { name: '预览' }) screen.queryByRole('tab', { name: '预览' })
@@ -6651,6 +6651,46 @@ describe('App', () => {
expect(sidebar).not.toHaveClass('assistant-sidebar--open') expect(sidebar).not.toHaveClass('assistant-sidebar--open')
}) })
it('keeps completed chat replies out of the results sidebar', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '普通聊天问题' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '这是一条普通聊天回复'
})
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
expect(
await screen.findByText('这是一条普通聊天回复')
).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(screen.getByRole('tab', { name: '成果' }))
const sidebar = screen.getByLabelText('助手工作栏')
expect(
within(sidebar).queryByText('这是一条普通聊天回复')
).not.toBeInTheDocument()
expect(
within(sidebar).getByText(
'生成的文件、图片、报告和手动导入内容会显示在这里。'
)
).toBeInTheDocument()
})
it('opens the live browser tab for the active conversation and can stop it', async () => { it('opens the live browser tab for the active conversation and can stop it', async () => {
render(<App />) render(<App />)
+4 -29
View File
@@ -2958,8 +2958,8 @@ function App(): React.JSX.Element {
[conversations] [conversations]
) )
const sidebarArtifacts = useMemo<SidebarArtifact[]>( const sidebarArtifacts = useMemo<SidebarArtifact[]>(
() => { () =>
const persisted = assistantArtifacts assistantArtifacts
.filter( .filter(
(artifact) => (artifact) =>
!activeProjectId || artifact.projectId === activeProjectId !activeProjectId || artifact.projectId === activeProjectId
@@ -2970,33 +2970,8 @@ function App(): React.JSX.Element {
content: artifact.content ?? '', content: artifact.content ?? '',
createdAt: new Date(artifact.createdAt).getTime(), createdAt: new Date(artifact.createdAt).getTime(),
mimeType: artifact.mimeType mimeType: artifact.mimeType
})) })),
if (persisted.length > 0) { [activeProjectId, assistantArtifacts]
return persisted
}
return (activeConversation?.messages ?? [])
.filter(
(message) =>
message.role === 'assistant' &&
message.state === 'complete' &&
message.content.trim()
)
.slice(-20)
.reverse()
.map((message, index) => ({
id: message.id,
title:
message.content
.split(/\r?\n/, 1)[0]
?.replace(/^#+\s*/, '')
.slice(0, 48) ||
t('chat.assistantResult', { index: index + 1 }),
content: message.content,
createdAt: message.createdAt,
mimeType: 'text/markdown'
}))
},
[activeConversation, activeProjectId, assistantArtifacts, t]
) )
const enabledSidebarLibraries = useMemo( const enabledSidebarLibraries = useMemo(
() => () =>
@@ -135,7 +135,6 @@ export const app = {
}, },
chat: { chat: {
user: 'You', user: 'You',
assistantResult: 'Assistant result {{index}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY WORKSPACE', eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?', title: 'What would you like to accomplish today?',
@@ -87,7 +87,7 @@ export const workspace = {
}, },
results: { results: {
label: 'Results', label: 'Results',
description: 'View generated or imported content' description: 'View generated or imported standalone results'
} }
}, },
tasks: { tasks: {
@@ -151,10 +151,11 @@ export const workspace = {
title: 'Results', title: 'Results',
loadingImage: 'Loading image…', loadingImage: 'Loading image…',
description: description:
'View and preview text, images, PDFs, and web content generated by conversations or imported manually.', 'View and preview text, images, PDFs, and web results generated by tasks or automations, or imported manually.',
sectionTitle: 'Conversation and imported results', sectionTitle: 'Generated and imported results',
import: 'Import PDF, image, or web page', import: 'Import PDF, image, or web page',
empty: 'Completed responses will appear here as previewable results.' empty:
'Generated files, images, reports, and manually imported content will appear here.'
}, },
browser: { browser: {
title: 'Live browser', title: 'Live browser',
@@ -132,7 +132,6 @@ export const app = {
}, },
chat: { chat: {
user: '用户', user: '用户',
assistantResult: '助手成果 {{index}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY 工作台', eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?', title: '今天想一起完成什么?',
@@ -83,7 +83,7 @@ export const workspace = {
}, },
results: { results: {
label: '成果', label: '成果',
description: '查看对话生成或手动导入的内容' description: '查看生成或手动导入的独立成果'
} }
}, },
tasks: { tasks: {
@@ -142,10 +142,10 @@ export const workspace = {
title: '成果', title: '成果',
loadingImage: '正在加载图片…', loadingImage: '正在加载图片…',
description: description:
'查看并预览由对话生成或手动导入的文本、图片、PDF 与网页内容。', '查看并预览由任务、自动化生成或手动导入的文本、图片、PDF 与网页成果。',
sectionTitle: '对话与导入成果', sectionTitle: '生成与导入成果',
import: '导入 PDF、图片或网页', import: '导入 PDF、图片或网页',
empty: '成的回复会作为可预览成果显示在这里。' empty: '成的文件、图片、报告和手动导入内容会显示在这里。'
}, },
browser: { browser: {
title: '实时浏览器', title: '实时浏览器',