feat: restore risk assessment workspace and improve page scrolling
This commit is contained in:
@@ -6,6 +6,8 @@ import { User, AlertTriangle, Key } from "lucide-react";
|
||||
import type { ChatMessage as ChatMessageType } from "@/lib/store";
|
||||
import ThoughtChain from "./ThoughtChain";
|
||||
import RetrievalCard from "./RetrievalCard";
|
||||
import RawPayloadCard from "./RawPayloadCard";
|
||||
import RawTraceExplorer from "./RawTraceExplorer";
|
||||
|
||||
interface Props {
|
||||
message: ChatMessageType;
|
||||
@@ -74,6 +76,12 @@ export default function ChatMessage({ message }: Props) {
|
||||
{message.retrievals && message.retrievals.length > 0 && (
|
||||
<RetrievalCard retrievals={message.retrievals} />
|
||||
)}
|
||||
{message.rawTraces && message.rawTraces.length > 0 && (
|
||||
<>
|
||||
<RawTraceExplorer rawTraces={message.rawTraces} />
|
||||
<RawPayloadCard rawTraces={message.rawTraces} />
|
||||
</>
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400 mt-1 pl-1">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useApp } from "@/lib/store";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import ChatInput from "./ChatInput";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import RawTraceExplorer from "./RawTraceExplorer";
|
||||
import { Sparkles, Workflow } from "lucide-react";
|
||||
|
||||
export default function ChatPanel() {
|
||||
const { messages } = useApp();
|
||||
const { messages, sessionId } = useApp();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
// 控制固定入口的展开/收起
|
||||
const [showSessionTraces, setShowSessionTraces] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -16,7 +19,7 @@ export default function ChatPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto scroll-shadow">
|
||||
<div className="flex-1 min-h-0 overflow-y-scroll scroll-shadow">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#002fa7] to-[#4070ff] flex items-center justify-center mb-4 shadow-lg shadow-[#002fa7]/15">
|
||||
@@ -37,6 +40,26 @@ export default function ChatPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
{/* 固定的会话报文检索入口 + 交互轨迹按钮 - 只在有对话时显示 */}
|
||||
{messages.length > 0 && (
|
||||
<div className="px-4 mb-3 space-y-2">
|
||||
<RawTraceExplorer
|
||||
sessionId={sessionId}
|
||||
defaultExpanded={showSessionTraces}
|
||||
/>
|
||||
|
||||
{/* 交互轨迹跳转按钮 */}
|
||||
<button
|
||||
onClick={() => window.location.href = `/trajectory?session=${sessionId}`}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white rounded-xl shadow-sm transition-all duration-200 hover:shadow-md group"
|
||||
>
|
||||
<Workflow className="w-4 h-4 group-hover:scale-110 transition-transform" />
|
||||
<span className="text-sm font-medium">查看完整交互轨迹</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表 */}
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, FileCode, Copy, Check } from "lucide-react";
|
||||
import type { RawTrace } from "@/lib/store";
|
||||
|
||||
interface Props {
|
||||
rawTraces: RawTrace[];
|
||||
}
|
||||
|
||||
export default function RawPayloadCard({ rawTraces }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expandedTraces, setExpandedTraces] = useState<Record<number, boolean>>({});
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
if (!rawTraces || rawTraces.length === 0) return null;
|
||||
|
||||
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-purple-50/50 to-blue-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-purple-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-purple-500" />
|
||||
)}
|
||||
<div className="w-5 h-5 rounded flex items-center justify-center bg-purple-100">
|
||||
<FileCode className="w-3 h-3 text-purple-600" />
|
||||
</div>
|
||||
<span className="font-semibold text-purple-700">
|
||||
📜 原始报文 ({rawTraces.length} 次 LLM 调用)
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-purple-500 font-mono">
|
||||
{rawTraces.reduce((sum, t) => sum + (t.usage?.total_tokens || 0), 0)} tokens
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-purple-100/50 bg-white/40">
|
||||
{rawTraces.map((trace, idx) => {
|
||||
const isTraceOpen = expandedTraces[idx] ?? false;
|
||||
const hasError = !!trace.error;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trace.trace_id}
|
||||
className="border-b border-purple-50/50 last:border-b-0"
|
||||
>
|
||||
{/* Trace 标题 */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedTraces((p) => ({ ...p, [idx]: !p[idx] }))
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-[11px] hover:bg-purple-50/30 transition-colors"
|
||||
>
|
||||
{isTraceOpen ? (
|
||||
<ChevronDown className="w-3 h-3 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-gray-400" />
|
||||
)}
|
||||
<span className="font-mono text-gray-500">#{idx + 1}</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{trace.request.model}
|
||||
</span>
|
||||
{trace.duration_ms !== undefined && (
|
||||
<span className="text-gray-500">{trace.duration_ms}ms</span>
|
||||
)}
|
||||
{trace.usage && (
|
||||
<span className="text-gray-500 font-mono text-[10px]">
|
||||
{trace.usage.input_tokens}→{trace.usage.output_tokens} tokens
|
||||
</span>
|
||||
)}
|
||||
{trace.stop_reason && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-mono">
|
||||
{trace.stop_reason}
|
||||
</span>
|
||||
)}
|
||||
{hasError && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-semibold">
|
||||
ERROR
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isTraceOpen && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{/* 错误信息 */}
|
||||
{hasError && 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>
|
||||
)}
|
||||
|
||||
{/* 原始请求 */}
|
||||
<CollapsibleSection
|
||||
title="📤 原始请求"
|
||||
id={`req-${trace.trace_id}`}
|
||||
expanded={expandedSections}
|
||||
setExpanded={setExpandedSections}
|
||||
onCopy={() =>
|
||||
handleCopy(
|
||||
JSON.stringify(trace.request, null, 2),
|
||||
`req-${trace.trace_id}`
|
||||
)
|
||||
}
|
||||
copied={copiedId === `req-${trace.trace_id}`}
|
||||
>
|
||||
<pre className="text-[10px] text-gray-700 font-mono whitespace-pre-wrap overflow-x-auto max-h-64 overflow-y-auto leading-relaxed">
|
||||
{JSON.stringify(trace.request, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 原始响应 */}
|
||||
{trace.response && (
|
||||
<CollapsibleSection
|
||||
title="📥 原始响应"
|
||||
id={`res-${trace.trace_id}`}
|
||||
expanded={expandedSections}
|
||||
setExpanded={setExpandedSections}
|
||||
onCopy={() =>
|
||||
handleCopy(
|
||||
JSON.stringify(trace.response, null, 2),
|
||||
`res-${trace.trace_id}`
|
||||
)
|
||||
}
|
||||
copied={copiedId === `res-${trace.trace_id}`}
|
||||
>
|
||||
<pre className="text-[10px] text-gray-700 font-mono whitespace-pre-wrap overflow-x-auto max-h-64 overflow-y-auto leading-relaxed">
|
||||
{JSON.stringify(trace.response, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 可折叠的子区域 (请求/响应) */
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
id,
|
||||
children,
|
||||
expanded,
|
||||
setExpanded,
|
||||
onCopy,
|
||||
copied,
|
||||
}: {
|
||||
title: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
expanded: Record<string, boolean>;
|
||||
setExpanded: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
onCopy: () => void;
|
||||
copied: boolean;
|
||||
}) {
|
||||
const isOpen = expanded[id] ?? false;
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white/60">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-gray-50/80 border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setExpanded((p) => ({ ...p, [id]: !p[id] }))}
|
||||
className="flex items-center gap-1.5 text-[10px] font-semibold text-gray-600 hover:text-gray-800 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-gray-200 rounded transition-colors"
|
||||
title="复制 JSON"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && <div className="p-2 bg-gray-900/[0.02]">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user