592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { ChevronDown, ChevronRight, Filter, Copy, Check, Loader2 } from "lucide-react";
|
|
import type { RawTrace } from "@/lib/store";
|
|
import { getRawTraces } from "@/lib/api";
|
|
|
|
interface Props {
|
|
// 会话级模式:传 sessionId,组件内部自行拉取全部 traces
|
|
sessionId?: string;
|
|
// 消息级模式:直接传 rawTraces(向后兼容)
|
|
rawTraces?: RawTrace[];
|
|
// 默认是否展开(固定入口用)
|
|
defaultExpanded?: boolean;
|
|
}
|
|
|
|
type FilterType = "all" | "stop" | "length" | "tool_calls" | "unknown";
|
|
|
|
export default function RawTraceExplorer({ sessionId, rawTraces: propRawTraces, defaultExpanded = false }: Props) {
|
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
const [filter, setFilter] = useState<FilterType>("all");
|
|
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(null);
|
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
// 会话级模式:自行拉取 traces
|
|
const [fetchedTraces, setFetchedTraces] = useState<RawTrace[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 当传入 sessionId 时,自动拉取该会话的全部 traces
|
|
useEffect(() => {
|
|
if (!sessionId) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
getRawTraces(sessionId, 50) // 拉取最近 50 条
|
|
.then((data) => {
|
|
setFetchedTraces(data.traces || []);
|
|
})
|
|
.catch((err) => {
|
|
setError(err.message || "拉取报文失败");
|
|
setFetchedTraces([]);
|
|
})
|
|
.finally(() => {
|
|
setLoading(false);
|
|
});
|
|
}, [sessionId]);
|
|
|
|
// 优先使用传入的 rawTraces,否则用拉取的
|
|
const rawTraces = propRawTraces || fetchedTraces;
|
|
|
|
// 如果没有 sessionId 也没有 rawTraces,不显示(向后兼容)
|
|
if (!sessionId && (!propRawTraces || propRawTraces.length === 0)) return null;
|
|
|
|
// 获取 finish_reason,向后兼容 stop_reason
|
|
const getFinishReason = (trace: RawTrace): string => {
|
|
return trace.finish_reason || trace.stop_reason || "unknown";
|
|
};
|
|
|
|
// 按 finish_reason 分类统计
|
|
const counts = {
|
|
all: rawTraces.length,
|
|
stop: rawTraces.filter((t) => getFinishReason(t) === "stop").length,
|
|
length: rawTraces.filter((t) => getFinishReason(t) === "length").length,
|
|
tool_calls: rawTraces.filter((t) => getFinishReason(t) === "tool_calls").length,
|
|
unknown: rawTraces.filter((t) => getFinishReason(t) === "unknown").length,
|
|
};
|
|
|
|
// 过滤后的 traces
|
|
const filteredTraces =
|
|
filter === "all"
|
|
? rawTraces
|
|
: rawTraces.filter((t) => getFinishReason(t) === filter);
|
|
|
|
const handleCopy = async (text: string, id: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopiedId(id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
} catch (err) {
|
|
console.error("复制失败:", err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mt-2 rounded-xl border border-black/[0.06] bg-gradient-to-br from-indigo-50/50 to-purple-50/50 overflow-hidden animate-fade-in-scale">
|
|
{/* 主标题 */}
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-[12px] hover:bg-black/[0.02] transition-colors"
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 text-indigo-500" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 text-indigo-500" />
|
|
)}
|
|
<div className="w-5 h-5 rounded flex items-center justify-center bg-indigo-100">
|
|
<Filter className="w-3 h-3 text-indigo-600" />
|
|
</div>
|
|
<span className="font-semibold text-indigo-700">
|
|
📡 会话报文检索
|
|
{loading ? (
|
|
<Loader2 className="inline-block w-3 h-3 ml-1.5 animate-spin" />
|
|
) : (
|
|
<> ({rawTraces.length} 次调用)</>
|
|
)}
|
|
</span>
|
|
<span className="ml-auto text-[10px] text-indigo-500 font-mono">
|
|
{rawTraces.reduce((sum, t) => sum + (t.usage?.total_tokens || 0), 0)} tokens
|
|
</span>
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="border-t border-indigo-100/50 bg-white/40">
|
|
{/* 加载中 / 错误状态 */}
|
|
{loading ? (
|
|
<div className="px-4 py-6 flex items-center justify-center gap-2 text-[11px] text-indigo-600">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<span>正在拉取报文...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="px-4 py-6 text-center text-[11px] text-red-600">
|
|
❌ {error}
|
|
</div>
|
|
) : rawTraces.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-[11px] text-gray-400">
|
|
暂无报文记录
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 筛选标签 */}
|
|
<div className="px-4 py-2.5 border-b border-indigo-50/50 flex items-center gap-2 flex-wrap">
|
|
<span className="text-[10px] text-gray-500 font-medium">筛选:</span>
|
|
<FilterButton
|
|
label="全部"
|
|
count={counts.all}
|
|
active={filter === "all"}
|
|
color="gray"
|
|
onClick={() => setFilter("all")}
|
|
/>
|
|
<FilterButton
|
|
label="stop"
|
|
count={counts.stop}
|
|
active={filter === "stop"}
|
|
color="green"
|
|
onClick={() => setFilter("stop")}
|
|
/>
|
|
<FilterButton
|
|
label="length"
|
|
count={counts.length}
|
|
active={filter === "length"}
|
|
color="orange"
|
|
onClick={() => setFilter("length")}
|
|
/>
|
|
<FilterButton
|
|
label="tool_calls"
|
|
count={counts.tool_calls}
|
|
active={filter === "tool_calls"}
|
|
color="blue"
|
|
onClick={() => setFilter("tool_calls")}
|
|
/>
|
|
<FilterButton
|
|
label="unknown"
|
|
count={counts.unknown}
|
|
active={filter === "unknown"}
|
|
color="gray"
|
|
onClick={() => setFilter("unknown")}
|
|
/>
|
|
</div>
|
|
|
|
{/* Trace 列表 */}
|
|
<div className="max-h-[500px] overflow-y-auto">
|
|
{filteredTraces.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-[11px] text-gray-400">
|
|
无匹配的报文记录
|
|
</div>
|
|
) : (
|
|
filteredTraces.map((trace, idx) => {
|
|
const finishReason = getFinishReason(trace);
|
|
const isSelected = selectedTraceId === trace.trace_id;
|
|
const hasError = !!trace.error;
|
|
|
|
// 提取响应文本摘要
|
|
let responsePreview = "";
|
|
if (trace.response?.generations?.[0]?.text) {
|
|
responsePreview = trace.response.generations[0].text.slice(0, 50);
|
|
}
|
|
|
|
// 提取工具调用信息
|
|
let toolNames: string[] = [];
|
|
if (trace.response?.generations?.[0]?.message?.tool_calls) {
|
|
toolNames = trace.response.generations[0].message.tool_calls.map(
|
|
(tc: any) => tc.name || tc.function?.name || "unknown"
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={trace.trace_id}
|
|
className="border-b border-indigo-50/50 last:border-b-0"
|
|
>
|
|
{/* Trace 摘要行 */}
|
|
<button
|
|
onClick={() =>
|
|
setSelectedTraceId(isSelected ? null : trace.trace_id)
|
|
}
|
|
className="w-full flex items-center gap-2 px-4 py-2 text-[11px] hover:bg-indigo-50/30 transition-colors"
|
|
>
|
|
{isSelected ? (
|
|
<ChevronDown className="w-3 h-3 text-gray-400 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3 text-gray-400 shrink-0" />
|
|
)}
|
|
<span className="font-mono text-gray-500 shrink-0">
|
|
#{idx + 1}
|
|
</span>
|
|
<span className="font-medium text-gray-700 shrink-0">
|
|
{trace.request.model}
|
|
</span>
|
|
<FinishReasonBadge reason={finishReason} />
|
|
{trace.duration_ms !== undefined && (
|
|
<span className="text-gray-500 shrink-0 text-[10px]">
|
|
{trace.duration_ms}ms
|
|
</span>
|
|
)}
|
|
{toolNames.length > 0 && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 font-mono shrink-0">
|
|
🔧 {toolNames.join(", ")}
|
|
</span>
|
|
)}
|
|
{responsePreview && (
|
|
<span className="text-[10px] text-gray-400 truncate flex-1 text-left">
|
|
{responsePreview}...
|
|
</span>
|
|
)}
|
|
{hasError && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-semibold shrink-0">
|
|
ERROR
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 完整 Trajectory 展开 */}
|
|
{isSelected && (
|
|
<TrajectoryView
|
|
trace={trace}
|
|
expandedSections={expandedSections}
|
|
setExpandedSections={setExpandedSections}
|
|
onCopy={handleCopy}
|
|
copiedId={copiedId}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 筛选按钮 */
|
|
function FilterButton({
|
|
label,
|
|
count,
|
|
active,
|
|
color,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
active: boolean;
|
|
color: "gray" | "green" | "orange" | "blue";
|
|
onClick: () => void;
|
|
}) {
|
|
const colorClasses = {
|
|
gray: active
|
|
? "bg-gray-200 text-gray-800 border-gray-300"
|
|
: "bg-gray-50 text-gray-600 border-gray-200",
|
|
green: active
|
|
? "bg-green-100 text-green-800 border-green-300"
|
|
: "bg-green-50 text-green-600 border-green-200",
|
|
orange: active
|
|
? "bg-orange-100 text-orange-800 border-orange-300"
|
|
: "bg-orange-50 text-orange-600 border-orange-200",
|
|
blue: active
|
|
? "bg-blue-100 text-blue-800 border-blue-300"
|
|
: "bg-blue-50 text-blue-600 border-blue-200",
|
|
};
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`px-2 py-0.5 rounded-full border text-[10px] font-medium transition-all hover:scale-105 ${colorClasses[color]}`}
|
|
>
|
|
{label} ({count})
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** finish_reason 彩色小标签 */
|
|
function FinishReasonBadge({ reason }: { reason: string }) {
|
|
const colorClasses = {
|
|
stop: "bg-green-100 text-green-700 border-green-200",
|
|
length: "bg-orange-100 text-orange-700 border-orange-200",
|
|
tool_calls: "bg-blue-100 text-blue-700 border-blue-200",
|
|
unknown: "bg-gray-100 text-gray-600 border-gray-200",
|
|
};
|
|
|
|
const colorClass =
|
|
colorClasses[reason as keyof typeof colorClasses] || colorClasses.unknown;
|
|
|
|
return (
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded border font-mono shrink-0 ${colorClass}`}
|
|
>
|
|
{reason}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** 完整 Trajectory 视图(请求→响应→工具调用→usage) */
|
|
function TrajectoryView({
|
|
trace,
|
|
expandedSections,
|
|
setExpandedSections,
|
|
onCopy,
|
|
copiedId,
|
|
}: {
|
|
trace: RawTrace;
|
|
expandedSections: Record<string, boolean>;
|
|
setExpandedSections: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
|
onCopy: (text: string, id: string) => void;
|
|
copiedId: string | null;
|
|
}) {
|
|
// 提取响应文本
|
|
let responseText = "";
|
|
let reasoningContent = "";
|
|
if (trace.response?.generations?.[0]) {
|
|
responseText = trace.response.generations[0].text || "";
|
|
// 尝试提取 reasoning_content
|
|
const msg = trace.response.generations[0].message;
|
|
if (msg?.additional_kwargs?.reasoning_content) {
|
|
reasoningContent = msg.additional_kwargs.reasoning_content;
|
|
}
|
|
}
|
|
|
|
// 提取工具调用
|
|
let toolCalls: any[] = [];
|
|
if (trace.response?.generations?.[0]?.message?.tool_calls) {
|
|
toolCalls = trace.response.generations[0].message.tool_calls;
|
|
}
|
|
|
|
return (
|
|
<div className="px-4 pb-3 space-y-2.5 bg-gradient-to-br from-white/60 to-indigo-50/20">
|
|
{/* 时间线可视化 */}
|
|
<div className="flex items-center gap-1.5 text-[10px] text-gray-500 py-2 border-b border-indigo-100/50">
|
|
<span className="font-semibold text-indigo-600">完整路径:</span>
|
|
<span>用户输入</span>
|
|
<span className="text-indigo-400">→</span>
|
|
<span>LLM 请求</span>
|
|
{toolCalls.length > 0 && (
|
|
<>
|
|
<span className="text-indigo-400">→</span>
|
|
<span className="text-blue-600 font-medium">[工具调用]</span>
|
|
</>
|
|
)}
|
|
<span className="text-indigo-400">→</span>
|
|
<span>LLM 响应</span>
|
|
</div>
|
|
|
|
{/* 错误信息 */}
|
|
{trace.error && (
|
|
<div className="p-2 bg-red-50 border border-red-200 rounded-lg">
|
|
<div className="text-[10px] font-semibold text-red-600 mb-1">
|
|
❌ 错误
|
|
</div>
|
|
<pre className="text-[10px] text-red-700 font-mono whitespace-pre-wrap">
|
|
{trace.error.type}: {trace.error.message}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* 1. 请求 messages */}
|
|
<CollapsibleSection
|
|
title="📤 请求 Messages"
|
|
id={`req-messages-${trace.trace_id}`}
|
|
expanded={expandedSections}
|
|
setExpanded={setExpandedSections}
|
|
onCopy={() =>
|
|
onCopy(
|
|
JSON.stringify(trace.request.messages, null, 2),
|
|
`req-messages-${trace.trace_id}`
|
|
)
|
|
}
|
|
copied={copiedId === `req-messages-${trace.trace_id}`}
|
|
>
|
|
<div className="space-y-1.5">
|
|
{trace.request.messages.map((msg: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-2 bg-white/80 rounded border border-gray-200"
|
|
>
|
|
<div className="text-[9px] font-semibold text-gray-500 mb-1">
|
|
{msg.type || msg.role || "message"} {idx + 1}
|
|
</div>
|
|
<pre className="text-[10px] text-gray-700 font-mono whitespace-pre-wrap max-h-32 overflow-y-auto">
|
|
{typeof msg.content === "string"
|
|
? msg.content
|
|
: JSON.stringify(msg.content, null, 2)}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* 2. 模型响应文本 */}
|
|
{(responseText || reasoningContent) && (
|
|
<CollapsibleSection
|
|
title="💬 模型响应文本"
|
|
id={`response-text-${trace.trace_id}`}
|
|
expanded={expandedSections}
|
|
setExpanded={setExpandedSections}
|
|
onCopy={() =>
|
|
onCopy(
|
|
reasoningContent
|
|
? `[Reasoning]\n${reasoningContent}\n\n[Response]\n${responseText}`
|
|
: responseText,
|
|
`response-text-${trace.trace_id}`
|
|
)
|
|
}
|
|
copied={copiedId === `response-text-${trace.trace_id}`}
|
|
>
|
|
<div className="space-y-2">
|
|
{reasoningContent && (
|
|
<div className="p-2 bg-purple-50/50 border border-purple-200 rounded">
|
|
<div className="text-[9px] font-semibold text-purple-600 mb-1">
|
|
🧠 Reasoning
|
|
</div>
|
|
<pre className="text-[10px] text-purple-700 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
|
{reasoningContent}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
<div className="p-2 bg-white/80 border border-gray-200 rounded">
|
|
<div className="text-[9px] font-semibold text-gray-500 mb-1">
|
|
📝 最终文本
|
|
</div>
|
|
<pre className="text-[10px] text-gray-700 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
|
|
{responseText}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* 3. 工具调用 */}
|
|
{toolCalls.length > 0 && (
|
|
<CollapsibleSection
|
|
title="🔧 工具调用"
|
|
id={`tool-calls-${trace.trace_id}`}
|
|
expanded={expandedSections}
|
|
setExpanded={setExpandedSections}
|
|
onCopy={() =>
|
|
onCopy(
|
|
JSON.stringify(toolCalls, null, 2),
|
|
`tool-calls-${trace.trace_id}`
|
|
)
|
|
}
|
|
copied={copiedId === `tool-calls-${trace.trace_id}`}
|
|
>
|
|
<div className="space-y-2">
|
|
{toolCalls.map((tc: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-2 bg-blue-50/50 border border-blue-200 rounded"
|
|
>
|
|
<div className="text-[9px] font-semibold text-blue-600 mb-1">
|
|
🔧 {tc.name || tc.function?.name || `Tool ${idx + 1}`}
|
|
</div>
|
|
<div className="text-[9px] text-gray-500 mb-1">参数:</div>
|
|
<pre className="text-[10px] text-blue-700 font-mono whitespace-pre-wrap max-h-32 overflow-y-auto">
|
|
{JSON.stringify(
|
|
tc.args || tc.function?.arguments || tc,
|
|
null,
|
|
2
|
|
)}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* 4. Usage 摘要 */}
|
|
{trace.usage && (
|
|
<div className="p-2 bg-gradient-to-r from-green-50/50 to-blue-50/50 border border-green-200 rounded-lg">
|
|
<div className="text-[9px] font-semibold text-green-600 mb-1">
|
|
📊 Token 使用情况
|
|
</div>
|
|
<div className="flex items-center gap-4 text-[10px] text-gray-700">
|
|
<span>
|
|
输入: <span className="font-mono font-semibold">{trace.usage.input_tokens}</span>
|
|
</span>
|
|
<span>
|
|
输出: <span className="font-mono font-semibold">{trace.usage.output_tokens}</span>
|
|
</span>
|
|
<span>
|
|
总计: <span className="font-mono font-semibold">{trace.usage.total_tokens}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 完整原始 JSON(可选) */}
|
|
<CollapsibleSection
|
|
title="📋 完整原始 JSON"
|
|
id={`full-json-${trace.trace_id}`}
|
|
expanded={expandedSections}
|
|
setExpanded={setExpandedSections}
|
|
onCopy={() =>
|
|
onCopy(JSON.stringify(trace, null, 2), `full-json-${trace.trace_id}`)
|
|
}
|
|
copied={copiedId === `full-json-${trace.trace_id}`}
|
|
defaultClosed
|
|
>
|
|
<pre className="text-[10px] text-gray-700 font-mono whitespace-pre-wrap overflow-x-auto max-h-96 overflow-y-auto leading-relaxed">
|
|
{JSON.stringify(trace, null, 2)}
|
|
</pre>
|
|
</CollapsibleSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 可折叠区域 */
|
|
function CollapsibleSection({
|
|
title,
|
|
id,
|
|
children,
|
|
expanded,
|
|
setExpanded,
|
|
onCopy,
|
|
copied,
|
|
defaultClosed = false,
|
|
}: {
|
|
title: string;
|
|
id: string;
|
|
children: React.ReactNode;
|
|
expanded: Record<string, boolean>;
|
|
setExpanded: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
|
onCopy: () => void;
|
|
copied: boolean;
|
|
defaultClosed?: boolean;
|
|
}) {
|
|
const isOpen = expanded[id] ?? !defaultClosed;
|
|
|
|
return (
|
|
<div className="border border-indigo-200 rounded-lg overflow-hidden bg-white/60">
|
|
<div className="flex items-center gap-2 px-2 py-1.5 bg-indigo-50/80 border-b border-indigo-200">
|
|
<button
|
|
onClick={() => setExpanded((p) => ({ ...p, [id]: !p[id] }))}
|
|
className="flex items-center gap-1.5 text-[10px] font-semibold text-indigo-700 hover:text-indigo-900 transition-colors flex-1"
|
|
>
|
|
{isOpen ? (
|
|
<ChevronDown className="w-3 h-3" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3" />
|
|
)}
|
|
{title}
|
|
</button>
|
|
<button
|
|
onClick={onCopy}
|
|
className="p-1 hover:bg-indigo-100 rounded transition-colors"
|
|
title="复制内容"
|
|
>
|
|
{copied ? (
|
|
<Check className="w-3 h-3 text-green-600" />
|
|
) : (
|
|
<Copy className="w-3 h-3 text-indigo-500" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
{isOpen && <div className="p-2">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|