trace-block.tsx
1 'use client' 2 3 import type { ChatTraceBlock } from '@/types' 4 5 interface Props { 6 trace: ChatTraceBlock 7 } 8 9 export function TraceBlock({ trace }: Props) { 10 const bgColor = trace.type === 'thinking' 11 ? 'bg-purple-500/[0.04] border-purple-500/10' 12 : trace.type === 'tool-call' 13 ? 'bg-sky-500/[0.04] border-sky-500/10' 14 : 'bg-emerald-500/[0.04] border-emerald-500/10' 15 16 const labelColor = trace.type === 'thinking' 17 ? 'text-purple-400/70' 18 : trace.type === 'tool-call' 19 ? 'text-sky-400/70' 20 : 'text-emerald-400/70' 21 22 const icon = trace.type === 'thinking' 23 ? '...' 24 : trace.type === 'tool-call' 25 ? '>' 26 : '<' 27 28 return ( 29 <details className={`my-1 rounded-[8px] border ${bgColor} overflow-hidden`} open={trace.collapsed === false || undefined}> 30 <summary className={`flex items-center gap-2 px-3 py-1.5 cursor-pointer select-none transition-colors hover:bg-white/[0.02] ${labelColor} [&::-webkit-details-marker]:hidden list-none`}> 31 <span className="font-mono text-[10px] shrink-0">{icon}</span> 32 <span className="text-[11px] font-600 truncate"> 33 {trace.label || trace.type.replace('-', ' ')} 34 </span> 35 </summary> 36 <div className="px-3 pb-2"> 37 <pre className={`text-[11px] leading-relaxed whitespace-pre-wrap break-words m-0 ${ 38 trace.type === 'thinking' 39 ? 'text-text-3/60 italic' 40 : 'text-text-3/70 font-mono' 41 }`}> 42 {trace.content.length > 2000 43 ? trace.content.slice(0, 2000) + '\n... (truncated)' 44 : trace.content} 45 </pre> 46 </div> 47 </details> 48 ) 49 } 50 51 /** Parse message text with [[prefix]] markers into text and trace blocks */ 52 export function parseTraceBlocks(text: string): Array<{ type: 'text'; content: string } | ChatTraceBlock> { 53 const blocks: Array<{ type: 'text'; content: string } | ChatTraceBlock> = [] 54 const regex = /\[\[(thinking|tool|tool-result|trace|meta)\]\]([\s\S]*?)(?=\[\[(thinking|tool|tool-result|trace|meta)\]\]|$)/g 55 56 let lastEnd = 0 57 let match: RegExpExecArray | null 58 59 while ((match = regex.exec(text)) !== null) { 60 // Add any text before this match 61 if (match.index > lastEnd) { 62 const before = text.slice(lastEnd, match.index).trim() 63 if (before) blocks.push({ type: 'text', content: before }) 64 } 65 66 const prefix = match[1] 67 const content = match[2].trim() 68 if (content) { 69 if (prefix === 'thinking' || prefix === 'trace') { 70 blocks.push({ type: 'thinking', content, collapsed: true }) 71 } else if (prefix === 'tool') { 72 const firstLine = content.split('\n')[0] || '' 73 blocks.push({ type: 'tool-call', content, label: firstLine.slice(0, 60), collapsed: true }) 74 } else if (prefix === 'tool-result') { 75 blocks.push({ type: 'tool-result', content, collapsed: true }) 76 } 77 // meta is ignored 78 } 79 80 lastEnd = match.index + match[0].length 81 } 82 83 // Add remaining text 84 if (lastEnd === 0) { 85 // No trace markers found 86 if (text.trim()) blocks.push({ type: 'text', content: text }) 87 } else if (lastEnd < text.length) { 88 const remaining = text.slice(lastEnd).trim() 89 if (remaining) blocks.push({ type: 'text', content: remaining }) 90 } 91 92 return blocks 93 }