Просмотр исходного кода

WIP: 按助手回合聚合思考与工具

[gstack-context]
Decisions: 同一连续助手回合聚合 reasoning/tools;按 Agent 和系统边界切分;正文消息剥离已聚合内部信息但保留身份
Remaining: 折叠组件、工具详细输入输出、时间线接入、浏览器回归
Skill: /frontend-skill
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
c55f1f9f27
2 измененных файлов с 102 добавлено и 0 удалено
  1. 73 0
      src/client/utils/turnTrace.ts
  2. 29 0
      tests/client/toolTimeline.test.ts

+ 73 - 0
src/client/utils/turnTrace.ts

@@ -0,0 +1,73 @@
+import type { UiMessage, UiToolCall } from '@/components/messages/types'
+
+export type TurnTraceEntry =
+  | { id: string; type: 'reasoning'; messageId: string; content: string }
+  | { id: string; type: 'tool'; messageId: string; tool: UiToolCall }
+
+export type TurnTraceGroup = {
+  id: string
+  kind: 'trace'
+  entries: TurnTraceEntry[]
+  status: 'running' | 'success' | 'error'
+}
+
+export type MessageTimelineRow =
+  | { id: string; kind: 'message'; message: UiMessage }
+  | TurnTraceGroup
+
+function assistantOwner(message: UiMessage): string {
+  return `${message.profile || ''}\u0000${message.author || ''}`
+}
+
+function hasVisibleMessage(message: UiMessage): boolean {
+  return Boolean(message.content.trim() || message.attachments?.length || message.error)
+}
+
+function traceStatus(messages: UiMessage[], entries: TurnTraceEntry[]): TurnTraceGroup['status'] {
+  if (messages.some(message => message.status === 'failed')
+    || entries.some(entry => entry.type === 'tool' && entry.tool.status === 'error')) return 'error'
+  if (messages.some(message => message.status === 'streaming')
+    || entries.some(entry => entry.type === 'tool' && ['running', 'pending'].includes(entry.tool.status))) return 'running'
+  return 'success'
+}
+
+function appendAssistantSegment(rows: MessageTimelineRow[], segment: UiMessage[]): void {
+  if (!segment.length) return
+  const entries: TurnTraceEntry[] = []
+  for (const message of segment) {
+    if (message.reasoning?.trim()) {
+      entries.push({ id: `reasoning:${message.id}`, type: 'reasoning', messageId: message.id, content: message.reasoning })
+    }
+    for (const tool of message.tools ?? []) {
+      entries.push({ id: `tool:${message.id}:${tool.id}`, type: 'tool', messageId: message.id, tool })
+    }
+  }
+  if (entries.length) {
+    rows.push({ id: `trace:${segment[0]!.id}`, kind: 'trace', entries, status: traceStatus(segment, entries) })
+  }
+  for (const message of segment) {
+    if (!hasVisibleMessage(message)) continue
+    rows.push({ id: `message:${message.id}`, kind: 'message', message: { ...message, reasoning: undefined, tools: undefined } })
+  }
+}
+
+/** Groups contiguous assistant reasoning/tools by turn while preserving visible messages. */
+export function buildMessageTimelineRows(messages: UiMessage[]): MessageTimelineRow[] {
+  const rows: MessageTimelineRow[] = []
+  let segment: UiMessage[] = []
+  let owner = ''
+  const flush = () => { appendAssistantSegment(rows, segment); segment = []; owner = '' }
+  for (const message of messages) {
+    if (message.role === 'assistant' && !message.timelineKind) {
+      const nextOwner = assistantOwner(message)
+      if (segment.length && nextOwner !== owner) flush()
+      owner = nextOwner
+      segment.push(message)
+      continue
+    }
+    flush()
+    rows.push({ id: `message:${message.id}`, kind: 'message', message })
+  }
+  flush()
+  return rows
+}

+ 29 - 0
tests/client/toolTimeline.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { chatMessagesToUi } from '@/components/workspace/viewModels'
 import { normalizeChatMessage } from '@/utils/normalize'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
 
 describe('ordinary chat tool timeline', () => {
   it('folds persisted function calls and their result rows into one expandable tool', () => {
@@ -38,4 +39,32 @@ describe('ordinary chat tool timeline', () => {
       timelineMetadata: { task_count: 2, completed_count: 2 },
     })
   })
+
+  it('groups one turn of reasoning and tools before its visible assistant reply', () => {
+    const rows = buildMessageTimelineRows([
+      { id: 'user', role: 'user', content: '检查一下' },
+      { id: 'thinking', role: 'assistant', content: '', reasoning: '先读取配置', tools: [{ id: 'read', name: 'read_file', status: 'success', input: { path: 'a' }, output: 'ok' }] },
+      { id: 'tool-two', role: 'assistant', content: '', reasoning: '再执行命令', tools: [{ id: 'run', name: 'terminal', status: 'running', input: { command: 'pwd' } }] },
+      { id: 'answer', role: 'assistant', content: '检查完成', reasoning: '组织答案' },
+    ])
+    expect(rows.map(row => [row.kind, row.id])).toEqual([
+      ['message', 'message:user'],
+      ['trace', 'trace:thinking'],
+      ['message', 'message:answer'],
+    ])
+    const trace = rows[1]
+    expect(trace?.kind === 'trace' ? trace.entries.map(entry => entry.type) : []).toEqual(['reasoning', 'tool', 'reasoning', 'tool', 'reasoning'])
+    expect(trace?.kind === 'trace' ? trace.status : '').toBe('running')
+    expect(rows[2]?.kind === 'message' ? rows[2].message : undefined).toMatchObject({ content: '检查完成', reasoning: undefined, tools: undefined })
+  })
+
+  it('does not merge assistant traces across agents or system events', () => {
+    const rows = buildMessageTimelineRows([
+      { id: 'a', role: 'assistant', author: '甲', content: '', reasoning: '甲思考' },
+      { id: 'b', role: 'assistant', author: '乙', content: '', reasoning: '乙思考' },
+      { id: 'system', role: 'system', content: '系统信息' },
+      { id: 'c', role: 'assistant', author: '乙', content: '', reasoning: '新回合' },
+    ])
+    expect(rows.map(row => row.id)).toEqual(['trace:a', 'trace:b', 'message:system', 'trace:c'])
+  })
 })