feat(frontend): CoT execution trace UI — TracePanel + full SSE event handling

Add TracePanel component for collapsible execution trace display,
extend Message interface with traceItems, and implement complete
SSE event processing (trace/status/content/error/done) in GeminiChat.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-09 13:00:50 +08:00
co-authored by Claude Sonnet 4.6
parent 722ec3e81d
commit 0207f34902
4 changed files with 405 additions and 5 deletions
+174 -2
View File
@@ -18,6 +18,7 @@ import {
deleteConversation,
type TicketSummaryData,
type AttachmentData,
type TraceItem,
} from "@/lib/api";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -63,6 +64,58 @@ const INITIAL_EXTENSIONS: Extension[] = [
},
];
// ── Trace helpers ─────────────────────────────────────────────────────────────
function appendTraceItem(
convId: string,
msgId: string,
item: TraceItem,
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== convId) return c;
return {
...c,
messages: c.messages.map((m) =>
m.id === msgId
? { ...m, traceItems: [...(m.traceItems ?? []), item] }
: m
),
};
})
);
}
function updateTraceItem(
convId: string,
msgId: string,
tool: string,
updates: Partial<TraceItem>,
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== convId) return c;
return {
...c,
messages: c.messages.map((m) => {
if (m.id !== msgId) return m;
const items = [...(m.traceItems ?? [])];
// Update the last running entry for this tool
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].tool === tool && items[i].itemStatus === "running") {
items[i] = { ...items[i], ...updates };
break;
}
}
return { ...m, traceItems: items };
}),
};
})
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export function GeminiChat() {
const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -245,7 +298,8 @@ export function GeminiChat() {
if (c.id !== streamConvId) return c;
const exists = c.messages.some((m) => m.id === aiMsgId);
if (!exists) {
// First token: create the assistant message
// First token: create the assistant message (preserve accumulated traceItems)
const traceItems = c.messages.find((m) => m.id === aiMsgId)?.traceItems ?? [];
return {
...c,
messages: [
@@ -255,6 +309,7 @@ export function GeminiChat() {
role: "assistant" as const,
content: event.content!,
timestamp: new Date(),
traceItems,
},
],
};
@@ -270,6 +325,87 @@ export function GeminiChat() {
};
})
);
} else if (event.type === "status") {
const item: TraceItem = {
id: `status-${event.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: "status",
title: event.stage ?? "处理中",
message: event.message,
itemStatus: "info",
startTs: event.ts ?? Date.now(),
};
// Ensure assistant message placeholder exists before appending trace
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
const exists = c.messages.some((m) => m.id === aiMsgId);
if (!exists) {
return {
...c,
messages: [
...c.messages,
{ id: aiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [item] },
],
};
}
return {
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m
),
};
})
);
} else if (event.type === "tool_start" && event.tool) {
const item: TraceItem = {
id: `${event.tool}-${event.ts ?? Date.now()}`,
type: "tool_start",
tool: event.tool,
title: event.title ?? event.tool,
inputSummary: event.input_summary,
itemStatus: "running",
startTs: event.ts ?? Date.now(),
};
// Ensure assistant message placeholder exists
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
const exists = c.messages.some((m) => m.id === aiMsgId);
if (!exists) {
return {
...c,
messages: [
...c.messages,
{ id: aiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [item] },
],
};
}
return {
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m
),
};
})
);
} else if (event.type === "tool_end" && event.tool) {
updateTraceItem(streamConvId, aiMsgId, event.tool, {
type: "tool_end",
outputSummary: event.output_summary,
itemStatus: "success",
durationMs: event.duration_ms,
}, setConversations);
} else if (event.type === "tool_error" && event.tool) {
updateTraceItem(streamConvId, aiMsgId, event.tool, {
type: "tool_error",
errorSummary: event.error_summary,
itemStatus: "error",
durationMs: event.duration_ms,
}, setConversations);
}
},
() => {
@@ -308,7 +444,7 @@ export function GeminiChat() {
return {
...c,
messages: c.messages.map((m) =>
m.id === msgId ? { ...m, content: "", id: newAiMsgId } : m
m.id === msgId ? { ...m, content: "", id: newAiMsgId, traceItems: [] } : m
),
};
})
@@ -336,6 +472,41 @@ export function GeminiChat() {
};
})
);
} else if (event.type === "status") {
const item: TraceItem = {
id: `status-${event.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: "status",
title: event.stage ?? "处理中",
message: event.message,
itemStatus: "info",
startTs: event.ts ?? Date.now(),
};
appendTraceItem(regenConvId, newAiMsgId, item, setConversations);
} else if (event.type === "tool_start" && event.tool) {
const item: TraceItem = {
id: `${event.tool}-${event.ts ?? Date.now()}`,
type: "tool_start",
tool: event.tool,
title: event.title ?? event.tool,
inputSummary: event.input_summary,
itemStatus: "running",
startTs: event.ts ?? Date.now(),
};
appendTraceItem(regenConvId, newAiMsgId, item, setConversations);
} else if (event.type === "tool_end" && event.tool) {
updateTraceItem(regenConvId, newAiMsgId, event.tool, {
type: "tool_end",
outputSummary: event.output_summary,
itemStatus: "success",
durationMs: event.duration_ms,
}, setConversations);
} else if (event.type === "tool_error" && event.tool) {
updateTraceItem(regenConvId, newAiMsgId, event.tool, {
type: "tool_error",
errorSummary: event.error_summary,
itemStatus: "error",
durationMs: event.duration_ms,
}, setConversations);
}
},
() => {
@@ -402,6 +573,7 @@ export function GeminiChat() {
<GeminiMessage
key={msg.id}
message={msg}
model={selectedModel}
onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
/>
);
+12 -2
View File
@@ -3,7 +3,8 @@
import { useState } from "react";
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check, Paperclip, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAttachmentDownloadUrl, type AttachmentData } from "@/lib/api";
import { getAttachmentDownloadUrl, type AttachmentData, type TraceItem } from "@/lib/api";
import { TracePanel } from "./TracePanel";
export interface Message {
id: string;
@@ -11,10 +12,12 @@ export interface Message {
content: string;
timestamp?: Date;
attachments?: AttachmentData[];
traceItems?: TraceItem[];
}
interface GeminiMessageProps {
message: Message;
model?: "flash" | "auto" | "pro";
onRegenerate?: (id: string) => void;
}
@@ -165,7 +168,7 @@ const GemIcon = ({ size = 18 }: { size?: number }) => (
</svg>
);
export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) {
export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
@@ -195,6 +198,13 @@ export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) {
<GemIcon />
{/* Content */}
<div className="flex-1 min-w-0">
{message.traceItems && message.traceItems.length > 0 && (
<TracePanel
items={message.traceItems}
model={model}
className="mb-3"
/>
)}
<div className="space-y-0.5">{renderContent(message.content)}</div>
{/* Attachments */}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import {
CheckCircle2,
XCircle,
ChevronDown,
ChevronRight,
Loader2,
Zap,
Brain,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { TraceItem } from "@/lib/api";
const TOOL_ICONS: Record<string, string> = {
kb_search: "🗂️",
web_search: "🌐",
ticket_list: "🎫",
ticket_detail: "🎫",
generate_document: "📄",
sandbox_run: "⚙️",
};
function StatusIcon({ status }: { status: TraceItem["itemStatus"] }) {
switch (status) {
case "running":
return (
<Loader2
size={13}
className="animate-spin text-[var(--gem-accent,#4285f4)] shrink-0"
/>
);
case "success":
return (
<CheckCircle2 size={13} className="text-emerald-400 shrink-0" />
);
case "error":
return <XCircle size={13} className="text-red-400 shrink-0" />;
case "info":
return (
<Brain
size={13}
className="text-[var(--gem-text-muted)] shrink-0"
/>
);
}
}
function formatDuration(ms?: number): string {
if (!ms) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function TraceItemRow({
item,
showDetails,
}: {
item: TraceItem;
showDetails: boolean;
}) {
const icon = item.tool ? (TOOL_ICONS[item.tool] ?? "🔧") : null;
return (
<div
className={cn(
"flex items-start gap-2 py-1.5 px-2 rounded-lg text-xs transition-colors",
item.itemStatus === "running" && "bg-[var(--gem-surface-2)]"
)}
>
<div className="mt-0.5">
<StatusIcon status={item.itemStatus} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{icon && <span>{icon}</span>}
<span
className={cn(
"font-medium",
item.itemStatus === "error"
? "text-red-400"
: "text-[var(--gem-text)]"
)}
>
{item.title}
</span>
{item.durationMs !== undefined && (
<span className="text-[var(--gem-text-muted)] ml-auto shrink-0">
{formatDuration(item.durationMs)}
</span>
)}
</div>
{showDetails && (
<div className="mt-0.5 space-y-0.5">
{item.inputSummary && (
<p className="text-[var(--gem-text-muted)] truncate">
{item.inputSummary}
</p>
)}
{item.outputSummary && (
<p className="text-[var(--gem-text-secondary)] truncate">
{item.outputSummary}
</p>
)}
{item.errorSummary && (
<p className="text-red-400 truncate">{item.errorSummary}</p>
)}
{item.message && item.type === "status" && (
<p className="text-[var(--gem-text-muted)]">{item.message}</p>
)}
</div>
)}
</div>
</div>
);
}
interface TracePanelProps {
items: TraceItem[];
model: "flash" | "auto" | "pro";
className?: string;
}
export function TracePanel({ items, model, className }: TracePanelProps) {
const isPro = model === "pro";
const [open, setOpen] = useState(isPro);
const hasRunning = items.some((i) => i.itemStatus === "running");
const summaryText = (() => {
const running = items.filter((i) => i.itemStatus === "running");
if (running.length > 0) {
return `正在 ${running[running.length - 1].title}...`;
}
const errors = items.filter((i) => i.type === "tool_error");
const tools = items.filter((i) => i.type === "tool_end");
if (errors.length > 0) {
return `已完成(${errors.length} 个工具调用失败)`;
}
if (tools.length > 0) {
const names = tools.map((t) => t.title).join("、");
return `已完成:${names}`;
}
return "正在分析...";
})();
return (
<Collapsible open={open} onOpenChange={setOpen} className={cn("w-full", className)}>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs",
"text-[var(--gem-text-muted)] hover:text-[var(--gem-text)]",
"hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer"
)}
>
<Zap
size={12}
className={cn(
"shrink-0",
hasRunning
? "text-[var(--gem-accent,#4285f4)] animate-pulse"
: "text-[var(--gem-text-muted)]"
)}
/>
<span className="flex-1 text-left truncate">{summaryText}</span>
{open ? (
<ChevronDown size={12} className="shrink-0" />
) : (
<ChevronRight size={12} className="shrink-0" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 ml-2 border-l border-[var(--gem-border)] pl-3 space-y-0.5">
{items.map((item) => (
<TraceItemRow key={item.id} item={item} showDetails={isPro} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
+27 -1
View File
@@ -112,9 +112,35 @@ export function getAttachmentDownloadUrl(attachmentId: string): string {
// ── SSE Chat Stream ──────────────────────────────────────────────────────────
export interface ChatStreamEvent {
type: "token" | "tool_start" | "tool_end" | "done";
type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error";
// token
content?: string;
// status
stage?: string;
message?: string;
// tool_start / tool_end / tool_error
tool?: string;
title?: string;
input_summary?: string;
output_summary?: string;
error_summary?: string;
status?: "success" | "error";
duration_ms?: number;
ts?: number;
}
export interface TraceItem {
id: string;
type: "status" | "tool_start" | "tool_end" | "tool_error";
tool?: string;
title: string;
message?: string;
inputSummary?: string;
outputSummary?: string;
errorSummary?: string;
itemStatus: "running" | "success" | "error" | "info";
durationMs?: number;
startTs: number;
}
export function streamChat(