feat: restore risk assessment workspace and improve page scrolling

This commit is contained in:
NathanAlex7
2026-09-07 02:28:37 +08:00
parent db5d6763f1
commit f2439ee5a3
65 changed files with 13612 additions and 2649 deletions
+591
View File
@@ -0,0 +1,591 @@
"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>
);
}