cc-haha/desktop/src/components/trace/TraceBadges.tsx
程序员阿江(Relakkes) d3d7566f0c refactor(trace): redesign trace UI with LangSmith-style two-pane layout
UI rebuild (desktop):
- TraceSession: replace 3-column layout with two panes — turn-grouped
  timeline tree (draggable splitter, search/filter, keyboard nav) and a
  section-flow detail panel (Response / Messages / System Prompt /
  Tools / Parameters / Raw), collapse state persists across spans
- Render LLM requests/responses semantically: messages as role-colored
  conversation with tool_use/tool_result pairing instead of raw JSON
  dumps; Raw fallback via CodeViewer for legacy truncated records
- TraceList: row-style list with model chips, mono metrics, hover
  actions; content-visibility rows (no virtualization, WebKit-safe)
- i18n synced across zh/en/jp/kr/zh-TW (+25/-55 keys)

Data & capture (server):
- Capture full bodies: preview cap 2048 -> 240k chars, stream cap
  256KB -> 1MB; list API trims previews to keep polling light; new
  GET /api/sessions/:id/trace/calls/:callId returns the full record
- Extract per-call token usage at read time (SSE + JSON + proxy
  wrapped); mtime-keyed read cache for the polling path
- Fix sensitive-key regex redacting *_tokens count fields, which made
  token stats always report 0

Frontend data layer:
- SSE stream reassembly (Anthropic + OpenAI chat) adapted from
  claude-tap (MIT, attribution in THIRD_PARTY_LICENSES.md), request/
  response body parsers, shared formatters, on-demand call detail
  cache; traceViewModel gains tokenUsage/isLifecycleNoise, drops
  fullRaw

Tested:
- bun run check:server (1201 pass)
- bun run check:desktop (lint + 1358 tests + build)
- Chromium walkthrough against real local traces: list, session tree,
  LLM semantic detail (new format), legacy fallback, tool detail
2026-06-11 01:02:51 +08:00

175 lines
6.3 KiB
TypeScript

import type { ReactNode } from 'react'
import {
AlertTriangle,
Bot,
CircleDot,
Clock3,
FileJson2,
GitBranch,
MessageSquareText,
RadioTower,
Sparkles,
Wrench,
} from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TraceSpan, TraceSpanStatus } from '../../lib/traceViewModel'
type TraceTranslator = ReturnType<typeof useTranslation>
export function TypeIcon({ span, size = 14 }: { span: TraceSpan; size?: number }) {
const { icon, className } = iconForSpan(span, size)
return (
<span className={`inline-flex shrink-0 items-center justify-center ${className}`} aria-hidden="true">
{icon}
</span>
)
}
function iconForSpan(span: TraceSpan, size: number): { icon: ReactNode; className: string } {
const tertiary = 'text-[var(--color-text-tertiary)]'
switch (span.kind) {
case 'llm':
return { icon: <Sparkles size={size} strokeWidth={2} />, className: 'text-[var(--color-brand)]' }
case 'tool':
return { icon: <Wrench size={size} strokeWidth={2} />, className: 'text-[var(--color-warning)]' }
case 'tool_result':
return { icon: <Wrench size={size} strokeWidth={2} />, className: tertiary }
case 'turn':
return { icon: <GitBranch size={size} strokeWidth={2} />, className: tertiary }
case 'session':
return { icon: <RadioTower size={size} strokeWidth={2} />, className: tertiary }
case 'event':
return span.status === 'error'
? { icon: <AlertTriangle size={size} strokeWidth={2} />, className: 'text-[var(--color-error)]' }
: { icon: <CircleDot size={size} strokeWidth={2} />, className: tertiary }
case 'message':
if (span.message?.type === 'assistant') {
return { icon: <Bot size={size} strokeWidth={2} />, className: tertiary }
}
if (span.message?.type === 'system') {
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
return { icon: <MessageSquareText size={size} strokeWidth={2} />, className: tertiary }
default:
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
}
export function StatusGlyph({ status }: { status: TraceSpanStatus }) {
if (status === 'error') {
return <AlertTriangle size={13} strokeWidth={2} className="shrink-0 text-[var(--color-error)]" aria-hidden="true" />
}
if (status === 'pending') {
return <Clock3 size={13} strokeWidth={2} className="shrink-0 animate-pulse-dot text-[var(--color-warning)]" aria-hidden="true" />
}
return null
}
export function StatusPill({ status }: { status: TraceSpanStatus }) {
const t = useTranslation()
const className = status === 'error'
? 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
: status === 'pending'
? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
: 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
const label = status === 'error'
? t('trace.status.error')
: status === 'pending'
? t('trace.status.pending')
: t('trace.status.ok')
return (
<span className={`inline-flex shrink-0 items-center rounded-[var(--radius-sm)] px-1.5 py-0.5 text-[10px] font-semibold ${className}`}>
{label}
</span>
)
}
export function MetaChip({
label,
value,
tone = 'default',
title,
}: {
label: string
value: string
tone?: 'default' | 'danger'
title?: string
}) {
return (
<span
className="inline-flex min-w-0 items-center gap-1 text-[10px]"
{...(title ? { title } : {})}
>
<span className="shrink-0 text-[var(--color-text-tertiary)]">{label}</span>
<span className={`truncate font-mono ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{value}
</span>
</span>
)
}
export function LiveBadge() {
const t = useTranslation()
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-success)]/25 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-success)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('trace.live')}
</span>
)
}
export function spanDisplayTitle(span: TraceSpan, t: TraceTranslator): string {
if (span.kind === 'message' && span.message) {
switch (span.message.type) {
case 'user': return t('trace.message.user')
case 'assistant': return t('trace.message.assistant')
case 'system': return t('trace.message.system')
case 'tool_use': return t('trace.message.toolRequest')
case 'tool_result': return t('trace.message.toolResult')
default: return span.message.type
}
}
if (span.kind === 'llm') {
return span.call?.model ?? span.call?.provider?.name ?? t('trace.modelCall')
}
if (span.kind === 'tool') {
return span.toolName ?? span.title
}
if (span.kind === 'tool_result') {
return span.status === 'error' ? t('trace.toolError') : t('trace.toolResult')
}
if (span.kind === 'event' && span.event) {
return traceEventPhaseLabel(span.event.phase, t)
}
if (span.kind === 'turn') {
return turnDisplayTitle(span.title, (span.turnIndex ?? 0) + 1, t)
}
return span.title
}
export function turnDisplayTitle(title: string, oneBasedIndex: number, t: TraceTranslator): string {
if (title === 'Session activity') return t('trace.sessionActivity')
const match = title.match(/^Turn (\d+)$/)
if (match) return t('trace.turnLabel', { index: match[1]! })
if (!title.trim()) return t('trace.turnLabel', { index: oneBasedIndex })
return title
}
export function traceEventPhaseLabel(phase: string, t: TraceTranslator): string {
switch (phase) {
case 'api_call_started': return t('trace.event.apiCallStarted')
case 'api_call_completed': return t('trace.event.apiCallCompleted')
case 'api_call_failed': return t('trace.event.apiCallFailed')
case 'response_capture_failed': return t('trace.event.responseCaptureFailed')
case 'upstream_fetch_started': return t('trace.event.upstreamFetchStarted')
case 'upstream_fetch_completed': return t('trace.event.upstreamFetchCompleted')
case 'upstream_fetch_failed': return t('trace.event.upstreamFetchFailed')
default:
return phase
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}