turnTrace.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. import type { UiMessage, UiToolCall } from '@/components/messages/types'
  2. import { collapseDuplicateFailures } from './messageFailure'
  3. export type TurnTraceEntry =
  4. | { id: string; type: 'reasoning'; messageId: string; content: string }
  5. | { id: string; type: 'tool'; messageId: string; tool: UiToolCall }
  6. export type TurnTraceGroup = {
  7. id: string
  8. kind: 'trace'
  9. entries: TurnTraceEntry[]
  10. status: 'running' | 'success' | 'error'
  11. }
  12. export type MessageTimelineRow =
  13. | { id: string; kind: 'message'; message: UiMessage }
  14. | TurnTraceGroup
  15. function assistantOwner(message: UiMessage): string {
  16. return `${message.profile || ''}\u0000${message.author || ''}`
  17. }
  18. function hasVisibleMessage(message: UiMessage): boolean {
  19. return Boolean((message.activity && message.status === 'streaming') || message.browserCard || message.serviceWarnings?.length || message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
  20. }
  21. function traceStatus(messages: UiMessage[], entries: TurnTraceEntry[]): TurnTraceGroup['status'] {
  22. if (messages.some(message => message.status === 'failed')
  23. || entries.some(entry => entry.type === 'tool' && entry.tool.status === 'error')) return 'error'
  24. if (messages.some(message => message.status === 'streaming')
  25. || entries.some(entry => entry.type === 'tool' && ['running', 'pending'].includes(entry.tool.status))) return 'running'
  26. return 'success'
  27. }
  28. function appendAssistantSegment(rows: MessageTimelineRow[], segment: UiMessage[], bodies?: WeakMap<UiMessage, UiMessage>): void {
  29. if (!segment.length) return
  30. const entries: TurnTraceEntry[] = []
  31. for (const message of segment) {
  32. if (message.reasoning?.trim()) {
  33. entries.push({ id: `reasoning:${message.id}`, type: 'reasoning', messageId: message.id, content: message.reasoning })
  34. }
  35. for (const tool of message.tools ?? []) {
  36. entries.push({ id: `tool:${message.id}:${tool.id}`, type: 'tool', messageId: message.id, tool })
  37. }
  38. }
  39. if (entries.length) {
  40. rows.push({ id: `trace:${segment[0]!.id}`, kind: 'trace', entries, status: traceStatus(segment, entries) })
  41. }
  42. for (const message of segment) {
  43. if (!hasVisibleMessage(message)) continue
  44. let body = bodies?.get(message)
  45. if (!body) {
  46. body = { ...message, reasoning: undefined, tools: undefined }
  47. bodies?.set(message, body)
  48. }
  49. rows.push({ id: `message:${message.id}`, kind: 'message', message: body })
  50. }
  51. }
  52. /** Groups contiguous assistant reasoning/tools by turn while preserving visible messages. */
  53. export function buildMessageTimelineRows(messages: UiMessage[], bodies?: WeakMap<UiMessage, UiMessage>): MessageTimelineRow[] {
  54. const rows: MessageTimelineRow[] = []
  55. let segment: UiMessage[] = []
  56. let owner = ''
  57. const flush = () => { appendAssistantSegment(rows, segment, bodies); segment = []; owner = '' }
  58. for (const message of collapseDuplicateFailures(messages)) {
  59. if (message.role === 'assistant' && !message.timelineKind) {
  60. const nextOwner = assistantOwner(message)
  61. if (segment.length && nextOwner !== owner) flush()
  62. owner = nextOwner
  63. segment.push(message)
  64. // An interim/final assistant bubble is a real timeline boundary. Keep
  65. // later running tools and text below it instead of hoisting their trace
  66. // above the earlier visible message merely because the author matches.
  67. if (hasVisibleMessage(message)) flush()
  68. continue
  69. }
  70. flush()
  71. rows.push({ id: `message:${message.id}`, kind: 'message', message })
  72. }
  73. flush()
  74. return rows
  75. }