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
+8
View File
@@ -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>
+27 -4
View File
@@ -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} />
))}
+212
View File
@@ -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>
);
}
+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>
);
}
+4 -4
View File
@@ -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>
+66 -45
View File
@@ -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>
);
+279
View File
@@ -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>
);
}
-216
View File
@@ -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; }