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>
|
||||
);
|
||||
}
|
||||
@@ -35,13 +35,13 @@ export default function Navbar() {
|
||||
<span className="font-semibold text-[14px] tracking-tight text-gray-800">
|
||||
mini OpenClaw
|
||||
</span>
|
||||
{/* 兴邦评审入口 */}
|
||||
{/* 兴邦智能体工作台入口 */}
|
||||
<button
|
||||
onClick={() => (window.location.href = "/xingbang")}
|
||||
onClick={() => (window.location.href = "/xingbang-unified")}
|
||||
className="ml-3 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-semibold transition-all
|
||||
bg-gradient-to-r from-[#002fa7] to-[#2563eb] text-white shadow-sm hover:shadow-md"
|
||||
bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-sm hover:shadow-md hover:from-emerald-500 hover:to-teal-500"
|
||||
>
|
||||
🏛️ 兴邦评审台
|
||||
🏢 兴邦金租智能评审
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
Wrench,
|
||||
Loader2,
|
||||
Database,
|
||||
Building2,
|
||||
Target,
|
||||
Search,
|
||||
Shield,
|
||||
FileText,
|
||||
Users,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { useApp } from "@/lib/store";
|
||||
import { getSessionTokenCount } from "@/lib/api";
|
||||
@@ -93,6 +100,46 @@ export default function Sidebar() {
|
||||
{/* Divider */}
|
||||
<div className="mx-3 h-px bg-black/[0.04]" />
|
||||
|
||||
{/* Xingbang Assistants section */}
|
||||
<div className="shrink-0 px-1.5 py-2">
|
||||
<p className="px-3 pt-1 pb-2 text-[10px] font-semibold text-gray-400 uppercase tracking-widest">
|
||||
兴邦助手
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
<a
|
||||
href="/risk-assess"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
<span>风险研判</span>
|
||||
</a>
|
||||
<a
|
||||
href="/rule-engine"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>规则引擎管理</span>
|
||||
</a>
|
||||
<a
|
||||
href="/historical-data"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
<span>历史数据分析</span>
|
||||
</a>
|
||||
<a
|
||||
href="/xingbang"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-gray-600 hover:bg-white/40 hover:text-gray-800 rounded-lg transition-all"
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
<span>五Agent流水线</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 h-px bg-black/[0.04]" />
|
||||
|
||||
{/* Raw Messages section */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center">
|
||||
@@ -254,23 +301,9 @@ function SessionItem({
|
||||
onRename: (title: string) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuOpen]);
|
||||
|
||||
// Focus input when renaming
|
||||
useEffect(() => {
|
||||
@@ -289,8 +322,7 @@ function SessionItem({
|
||||
}, [renameValue, title, onRename]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
if (confirm("Delete this session?")) {
|
||||
if (confirm("删除该会话?此操作不可恢复。")) {
|
||||
onDelete();
|
||||
}
|
||||
}, [onDelete]);
|
||||
@@ -329,7 +361,7 @@ function SessionItem({
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-[13px] rounded-lg transition-all text-left relative pr-8 ${
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-[13px] rounded-lg transition-all text-left relative pr-16 ${
|
||||
isActive
|
||||
? "bg-white/70 text-gray-800 font-medium shadow-sm"
|
||||
: "text-gray-500 hover:bg-white/40"
|
||||
@@ -342,40 +374,29 @@ function SessionItem({
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
|
||||
{/* More button */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2" ref={menuRef}>
|
||||
{/* Action buttons: Rename and Delete */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen((v) => !v);
|
||||
setRenameValue(title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className="p-1 rounded-md text-gray-300 opacity-0 group-hover:opacity-100 hover:text-gray-600 hover:bg-black/[0.04] transition-all"
|
||||
className="p-1 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
title="重命名"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
className="p-1 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="删除会话"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-32 bg-white rounded-lg shadow-lg border border-black/[0.06] py-1 z-50 animate-fade-in-scale">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setRenameValue(title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
User,
|
||||
Bot,
|
||||
Wrench,
|
||||
CheckCircle2,
|
||||
Terminal,
|
||||
Code,
|
||||
Globe,
|
||||
FileText,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
// 工具图标映射
|
||||
const TOOL_META: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
terminal: { icon: Terminal, color: "#6b7280", bg: "#f3f4f6" },
|
||||
python_repl: { icon: Code, color: "#2563eb", bg: "#eff6ff" },
|
||||
fetch_url: { icon: Globe, color: "#059669", bg: "#ecfdf5" },
|
||||
read_file: { icon: FileText, color: "#d97706", bg: "#fffbeb" },
|
||||
search_knowledge_base: { icon: Search, color: "#7c3aed", bg: "#f5f3ff" },
|
||||
};
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
tool_calls?: Array<{ name: string; args: any }>;
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export default function AgentLoopFlow({ messages }: Props) {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleNode = (key: string) => {
|
||||
setExpandedNodes((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg, idx) => {
|
||||
const nodeKey = `msg-${idx}`;
|
||||
const isExpanded = expandedNodes[nodeKey];
|
||||
|
||||
// 人类消息
|
||||
if (msg.role === "human") {
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4">
|
||||
<div className="relative z-10 w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase">用户</span>
|
||||
{msg.timestamp && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(msg.timestamp * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant 消息
|
||||
if (msg.role === "assistant") {
|
||||
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4">
|
||||
<div className="relative z-10 w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl border border-purple-200 shadow-sm overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleNode(nodeKey)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/50 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="text-xs font-semibold text-purple-700 uppercase">
|
||||
Assistant
|
||||
</span>
|
||||
{msg.timestamp && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(msg.timestamp * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded flex items-center gap-1">
|
||||
<Wrench className="w-3 h-3" />
|
||||
调用 {msg.tool_calls?.length || 0} 个工具
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.content && !isExpanded && (
|
||||
<p className="text-sm text-gray-700 line-clamp-2">
|
||||
{msg.content.slice(0, 100)}
|
||||
{msg.content.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-purple-200 pt-3 bg-white/50">
|
||||
{msg.content && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase mb-1.5">
|
||||
回复内容
|
||||
</div>
|
||||
<div className="p-3 bg-white border border-gray-200 rounded-lg text-sm text-gray-800 whitespace-pre-wrap max-h-60 overflow-y-auto">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolCalls && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase mb-1.5">
|
||||
工具调用
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{msg.tool_calls?.map((tc, tcIdx) => {
|
||||
const toolKey = `${nodeKey}-tc-${tcIdx}`;
|
||||
const isToolExpanded = expandedNodes[toolKey];
|
||||
const meta = TOOL_META[tc.name] || TOOL_META.terminal;
|
||||
const ToolIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div key={tcIdx} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleNode(toolKey)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-50 transition-colors text-left"
|
||||
>
|
||||
{isToolExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-gray-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: meta.bg }}
|
||||
>
|
||||
<ToolIcon className="w-3.5 h-3.5" style={{ color: meta.color }} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
{tc.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isToolExpanded && (
|
||||
<div className="px-3 pb-2 border-t border-gray-200 pt-2 bg-gray-50">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase mb-1">
|
||||
传入参数
|
||||
</div>
|
||||
<pre className="p-2 bg-white border border-gray-200 rounded text-xs overflow-x-auto font-mono text-gray-700 max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(tc.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool 消息(工具返回)
|
||||
if (msg.role === "tool") {
|
||||
const meta = TOOL_META[msg.name || "terminal"] || TOOL_META.terminal;
|
||||
const ToolIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4 ml-14">
|
||||
<div
|
||||
className="relative z-10 w-10 h-10 rounded-full flex items-center justify-center shadow-lg flex-shrink-0"
|
||||
style={{ background: meta.bg }}
|
||||
>
|
||||
<ToolIcon className="w-5 h-5" style={{ color: meta.color }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white rounded-xl border-2 border-emerald-200 shadow-sm overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleNode(nodeKey)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-emerald-50/30 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold text-emerald-700 uppercase">
|
||||
工具返回
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{msg.name}
|
||||
</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
{msg.timestamp && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(msg.timestamp * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-1 font-mono">
|
||||
{msg.content.slice(0, 80)}
|
||||
{msg.content.length > 80 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-2 border-t border-emerald-200 pt-3 bg-emerald-50/20">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase mb-1.5">
|
||||
返回内容
|
||||
</div>
|
||||
<pre className="p-3 bg-slate-800 text-slate-100 rounded-lg text-xs overflow-x-auto max-h-96 overflow-y-auto font-mono leading-relaxed">
|
||||
{msg.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未知消息类型(兜底)
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4">
|
||||
<div className="relative z-10 w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center shadow-lg flex-shrink-0">
|
||||
<span className="text-white text-xs">?</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-100 rounded-xl border border-gray-300 p-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase mb-2">
|
||||
未知消息类型: {msg.role}
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap">
|
||||
{JSON.stringify(msg, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/* ═══ 兴邦金租 AI 评审平台 · 深色商务风专用样式 ═══ */
|
||||
|
||||
.xb-root {
|
||||
--xb-bg-0: #05070f;
|
||||
--xb-bg-1: #0a1020;
|
||||
--xb-bg-2: #101a33;
|
||||
--xb-line: rgba(120, 160, 255, 0.14);
|
||||
--xb-line-strong: rgba(120, 160, 255, 0.28);
|
||||
--xb-text: #e8eefc;
|
||||
--xb-muted: #8fa3c8;
|
||||
--xb-blue: #38bdf8;
|
||||
--xb-deep: #2563eb;
|
||||
--xb-gold: #f5b942;
|
||||
--xb-green: #34d399;
|
||||
--xb-red: #f87171;
|
||||
--xb-card: rgba(15, 24, 46, 0.72);
|
||||
|
||||
background:
|
||||
radial-gradient(1200px 600px at 15% -10%, rgba(37, 99, 235, 0.18), transparent 60%),
|
||||
radial-gradient(1000px 500px at 90% 0%, rgba(56, 189, 248, 0.12), transparent 55%),
|
||||
radial-gradient(1200px 800px at 50% 110%, rgba(15, 40, 90, 0.5), transparent 60%),
|
||||
linear-gradient(180deg, var(--xb-bg-1), var(--xb-bg-0));
|
||||
color: var(--xb-text);
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.xb-glass {
|
||||
background: var(--xb-card);
|
||||
border: 1px solid var(--xb-line);
|
||||
backdrop-filter: blur(14px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.xb-glass-hover {
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.xb-glass-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--xb-line-strong);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* 顶部渐进标题 */
|
||||
.xb-title-grad {
|
||||
background: linear-gradient(120deg, #ffffff 0%, var(--xb-blue) 55%, var(--xb-gold) 130%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xb-muted { color: var(--xb-muted); }
|
||||
|
||||
/* 流水线步骤连线 */
|
||||
.xb-pipe-connector {
|
||||
height: 2px;
|
||||
flex: 1;
|
||||
margin: 0 6px;
|
||||
background: linear-gradient(90deg, var(--xb-line), var(--xb-line));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 徽章 */
|
||||
.xb-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--xb-line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.xb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 22px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: linear-gradient(135deg, var(--xb-deep), var(--xb-blue));
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
.xb-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
.xb-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.xb-btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--xb-line-strong);
|
||||
color: var(--xb-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
.xb-btn-ghost:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
/* 输入 */
|
||||
.xb-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid var(--xb-line);
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
color: var(--xb-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.xb-input:focus { border-color: var(--xb-blue); }
|
||||
.xb-input::placeholder { color: #5c6f96; }
|
||||
|
||||
.xb-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--xb-muted);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 流水线步骤节点状态 */
|
||||
.xb-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.xb-node-chip {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid var(--xb-line-strong);
|
||||
background: rgba(20, 32, 60, 0.8);
|
||||
color: var(--xb-muted);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.xb-node.running .xb-node-chip {
|
||||
border-color: var(--xb-blue);
|
||||
color: var(--xb-blue);
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.15), 0 0 24px rgba(56, 189, 248, 0.3);
|
||||
animation: xb-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.xb-node.done .xb-node-chip {
|
||||
border-color: var(--xb-green);
|
||||
color: var(--xb-green);
|
||||
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
.xb-node.error .xb-node-chip {
|
||||
border-color: var(--xb-red);
|
||||
color: var(--xb-red);
|
||||
}
|
||||
.xb-node.pending .xb-node-chip { opacity: 0.55; }
|
||||
|
||||
.xb-node-name { font-size: 12px; font-weight: 600; }
|
||||
.xb-node-role { font-size: 10.5px; color: var(--xb-muted); }
|
||||
|
||||
@keyframes xb-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.07); }
|
||||
}
|
||||
|
||||
.xb-spinner {
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(56, 189, 248, 0.25);
|
||||
border-top-color: var(--xb-blue);
|
||||
border-radius: 50%;
|
||||
animation: xb-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes xb-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 表格 */
|
||||
.xb-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.xb-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--xb-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
border-bottom: 1px solid var(--xb-line);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.xb-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid rgba(120, 160, 255, 0.08);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* 风险等级徽章 */
|
||||
.xb-risk-hi { color: var(--xb-red); font-weight: 700; }
|
||||
.xb-risk-mid { color: var(--xb-gold); font-weight: 700; }
|
||||
.xb-risk-low { color: var(--xb-green); font-weight: 700; }
|
||||
|
||||
/* 滚动条 */
|
||||
.xb-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.xb-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 189, 248, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xb-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
Reference in New Issue
Block a user