feat: add ai trace admin console

This commit is contained in:
2026-06-23 00:32:32 +08:00
parent c75f0b646a
commit 81977ead8f
15 changed files with 651 additions and 10 deletions
@@ -0,0 +1,316 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Bot,
Clock3,
Database,
FileText,
RefreshCcw,
Search,
Sigma
} from "lucide-react";
type RetrievalSource = {
chunk_id?: string | null;
document_id?: string | null;
knowledge_base_id?: string | null;
title: string;
source_type: string;
reference: string;
locator?: string | null;
score?: number;
keyword_score?: number;
vector_score?: number;
hybrid_score?: number;
retrieval_strategy?: string;
matched_terms?: string[];
};
type AiRunAudit = {
id: string;
operation: string;
provider_config_id?: string | null;
prompt_template_id?: string | null;
provider?: string | null;
model?: string | null;
provider_status?: string | null;
provider_error?: string | null;
prompt_preview?: string | null;
question?: string | null;
answer?: string | null;
citations_count?: number;
retrieval_query?: string | null;
retrieval_strategy?: string | null;
retrieved_chunk_ids?: string[];
retrieved_sources?: RetrievalSource[];
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
estimated_cost?: number;
latency_ms?: number;
status: string;
created_at: string;
};
type LoadState =
| { status: "loading" }
| { status: "ready"; runs: AiRunAudit[] }
| { status: "error"; message: string };
const statusLabels: Record<string, string> = {
succeeded: "成功",
fallback: "已回退",
failed: "失败",
running: "运行中",
pending: "等待中",
skipped: "已跳过",
cancelled: "已取消"
};
const statusTone: Record<string, string> = {
succeeded: "tag-success",
fallback: "tag-warning",
failed: "tag-danger",
running: "tag-primary",
pending: "tag-primary",
skipped: "",
cancelled: "tag-danger"
};
export default function AiTracesPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [statusFilter, setStatusFilter] = useState("all");
const [keyword, setKeyword] = useState("");
async function loadRuns() {
setState({ status: "loading" });
try {
const response = await fetch("/api/ai-platform/audit/ai-runs?limit=50", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setState({ status: "ready", runs: Array.isArray(payload) ? payload : [] });
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "AI 审计数据加载失败"
});
}
}
useEffect(() => {
void loadRuns();
}, []);
const runs = state.status === "ready" ? state.runs : [];
const filteredRuns = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return runs.filter((run) => {
const matchesStatus = statusFilter === "all" || run.status === statusFilter;
const haystack = [
run.id,
run.operation,
run.provider,
run.model,
run.question,
run.answer,
run.provider_error,
run.retrieval_query,
...(run.retrieved_sources ?? []).map((source) => `${source.title} ${source.reference}`)
].join("\n").toLowerCase();
return matchesStatus && (!normalizedKeyword || haystack.includes(normalizedKeyword));
});
}, [keyword, runs, statusFilter]);
const metrics = useMemo(() => {
const totalTokens = runs.reduce((sum, run) => sum + (run.total_tokens ?? 0), 0);
const estimatedCost = runs.reduce((sum, run) => sum + (run.estimated_cost ?? 0), 0);
const failedCount = runs.filter((run) => run.status === "failed" || run.provider_status === "failed").length;
const averageLatency = runs.length
? Math.round(runs.reduce((sum, run) => sum + (run.latency_ms ?? 0), 0) / runs.length)
: 0;
return { totalTokens, estimatedCost, failedCount, averageLatency };
}, [runs]);
return (
<>
<div className="page-heading">
<div>
<h1>AI 调用 Trace</h1>
<p>查看模型、Prompt、知识命中、token、费用、错误和耗时。</p>
</div>
<button className="button button-primary" onClick={() => void loadRuns()}>
<RefreshCcw size={16} />
刷新
</button>
</div>
<div className="metric-row">
<Metric icon={<Bot size={18} />} label="最近调用" value={String(runs.length)} />
<Metric icon={<Sigma size={18} />} label="总 token" value={formatInteger(metrics.totalTokens)} />
<Metric icon={<Clock3 size={18} />} label="平均耗时" value={`${metrics.averageLatency} ms`} />
<Metric icon={<AlertTriangle size={18} />} label="异常调用" value={String(metrics.failedCount)} />
</div>
<section className="panel">
<div className="panel-header">
<h2 className="panel-title">调用列表</h2>
<span className="tag">预估费用 {formatCost(metrics.estimatedCost)}</span>
</div>
<div className="panel-body">
<div className="toolbar">
<div className="search-box">
<Search size={16} />
<input
className="search-input search-input-inline"
placeholder="搜索问题、模型、错误、法条来源"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</div>
<select
className="search-input select-input"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">全部状态</option>
<option value="succeeded">成功</option>
<option value="fallback">已回退</option>
<option value="failed">失败</option>
</select>
</div>
{state.status === "loading" && <div className="empty-state">正在加载 AI 调用审计...</div>}
{state.status === "error" && (
<div className="empty-state empty-state-error">
<strong>无法连接 AI Platform 审计接口</strong>
<span>{state.message}</span>
</div>
)}
{state.status === "ready" && filteredRuns.length === 0 && (
<div className="empty-state">暂无匹配的 AI 调用记录。</div>
)}
{state.status === "ready" && filteredRuns.length > 0 && (
<div className="trace-list">
{filteredRuns.map((run) => (
<TraceItem key={run.id} run={run} />
))}
</div>
)}
</div>
</section>
</>
);
}
function Metric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="metric">
<div className="metric-icon">{icon}</div>
<div className="metric-value">{value}</div>
<div className="metric-label">{label}</div>
</div>
);
}
function TraceItem({ run }: { run: AiRunAudit }) {
const statusClass = statusTone[run.status] ?? "";
const providerStatusClass = statusTone[run.provider_status ?? ""] ?? "";
return (
<details className="trace-item">
<summary>
<div className="trace-summary-main">
<div className="list-item-header">
<strong>{run.question || run.operation}</strong>
<span className={`tag ${statusClass}`}>{statusLabels[run.status] ?? run.status}</span>
</div>
<div className="trace-meta">
<span>{formatDate(run.created_at)}</span>
<span>{run.provider || "unknown"} / {run.model || "unknown"}</span>
<span>{formatInteger(run.total_tokens ?? 0)} tokens</span>
<span>{run.latency_ms ?? 0} ms</span>
<span>{formatCost(run.estimated_cost ?? 0)}</span>
</div>
</div>
</summary>
<div className="trace-detail-grid">
<section className="trace-block">
<h3><FileText size={16} /> Prompt</h3>
<pre>{run.prompt_preview || "暂无 Prompt 摘要"}</pre>
</section>
<section className="trace-block">
<h3><Bot size={16} /> 回答</h3>
<pre>{run.answer || "暂无回答"}</pre>
</section>
<section className="trace-block">
<h3><Database size={16} /> 知识命中</h3>
{(run.retrieved_sources ?? []).length === 0 ? (
<p className="muted">未记录知识命中。</p>
) : (
<div className="list">
{(run.retrieved_sources ?? []).map((source) => (
<div className="list-item compact-list-item" key={`${source.chunk_id}-${source.reference}`}>
<div className="list-item-header">
<strong>{source.title}</strong>
<span className="tag">score {formatScore(source.score)}</span>
</div>
<span className="item-meta">
{source.reference} · {source.locator || "无定位"} · {source.knowledge_base_id || "默认知识库"}
</span>
<span className="item-meta">
keyword {formatScore(source.keyword_score)} / vector {formatScore(source.vector_score)} / hybrid {formatScore(source.hybrid_score)}
</span>
</div>
))}
</div>
)}
</section>
<section className="trace-block">
<h3><AlertTriangle size={16} /> 运行状态</h3>
<div className="trace-facts">
<span>业务状态</span><strong>{statusLabels[run.status] ?? run.status}</strong>
<span>Provider 状态</span><strong className={`tag ${providerStatusClass}`}>{run.provider_status || "unknown"}</strong>
<span>Prompt tokens</span><strong>{formatInteger(run.prompt_tokens ?? 0)}</strong>
<span>Completion tokens</span><strong>{formatInteger(run.completion_tokens ?? 0)}</strong>
<span>总 tokens</span><strong>{formatInteger(run.total_tokens ?? 0)}</strong>
<span>预估费用</span><strong>{formatCost(run.estimated_cost ?? 0)}</strong>
<span>检索策略</span><strong>{run.retrieval_strategy || "unknown"}</strong>
<span>Trace ID</span><strong>{run.id}</strong>
</div>
{run.provider_error && <pre className="trace-error">{run.provider_error}</pre>}
</section>
</div>
</details>
);
}
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function formatInteger(value: number) {
return new Intl.NumberFormat("zh-CN").format(value);
}
function formatCost(value: number) {
return `$${value.toFixed(6)}`;
}
function formatScore(value: number | undefined) {
return (value ?? 0).toFixed(2);
}
@@ -1,6 +1,15 @@
import { Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { Bot, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
const adminSections = [
type AdminSection = {
title: string;
description: string;
icon: LucideIcon;
href?: string;
};
const adminSections: AdminSection[] = [
{
title: "企业权限",
description: "组织、角色、合同可见范围和操作边界。",
@@ -9,7 +18,14 @@ const adminSections = [
{
title: "AI 参数",
description: "模型连接、运行参数、提示词版本和审计策略。",
icon: SlidersHorizontal
icon: SlidersHorizontal,
href: "/admin/ai-traces"
},
{
title: "AI 调用 Trace",
description: "查看模型、Prompt、知识命中、token、费用、错误和耗时。",
icon: Bot,
href: "/admin/ai-traces"
},
{
title: "系统设置",
@@ -37,6 +53,11 @@ export default function AdminPage() {
<Icon size={22} />
<h2 className="panel-title">{section.title}</h2>
<p className="muted">{section.description}</p>
{section.href && (
<Link className="button button-soft" href={section.href}>
进入
</Link>
)}
</div>
</section>
);
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit") ?? "50";
const targetUrl = new URL("/api/v1/audit/ai-runs", aiPlatformBaseUrl);
targetUrl.searchParams.set("limit", limit);
try {
const response = await fetch(targetUrl, { cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform audit service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform audit service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -260,6 +260,18 @@ select {
font-size: 13px;
}
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-bottom: 10px;
border-radius: 8px;
color: var(--primary);
background: var(--primary-soft);
}
.list {
display: grid;
gap: 10px;
@@ -312,6 +324,36 @@ select {
background: #ffffff;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
min-width: min(100%, 420px);
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.search-box svg {
flex: 0 0 auto;
color: var(--muted);
}
.search-input-inline {
min-width: 0;
width: 100%;
min-height: 34px;
padding: 0;
border: 0;
outline: 0;
}
.select-input {
min-width: 160px;
}
.tag {
display: inline-flex;
align-items: center;
@@ -445,6 +487,126 @@ select {
border-bottom: 0;
}
.empty-state {
display: grid;
gap: 6px;
min-height: 120px;
align-content: center;
justify-items: center;
padding: 22px;
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
background: #fbfcfe;
text-align: center;
}
.empty-state-error {
color: var(--danger);
border-color: #f2b8b8;
background: var(--danger-soft);
}
.trace-list {
display: grid;
gap: 12px;
}
.trace-item {
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.trace-item summary {
display: block;
cursor: pointer;
list-style: none;
padding: 14px;
}
.trace-item summary::-webkit-details-marker {
display: none;
}
.trace-summary-main {
display: grid;
gap: 8px;
}
.trace-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
color: var(--muted);
font-size: 13px;
}
.trace-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 0 14px 14px;
}
.trace-block {
display: grid;
gap: 10px;
min-width: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfe;
}
.trace-block h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 14px;
}
.trace-block pre,
.trace-error {
overflow: auto;
max-height: 260px;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
color: #233044;
background: #ffffff;
font: 13px/1.7 "SFMono-Regular", Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
word-break: break-word;
}
.trace-error {
color: var(--danger);
border-color: #f2b8b8;
background: #fff7f7;
}
.trace-facts {
display: grid;
grid-template-columns: minmax(120px, 0.6fr) minmax(0, 1fr);
gap: 8px 12px;
align-items: center;
color: var(--muted);
font-size: 13px;
}
.trace-facts strong {
min-width: 0;
color: var(--ink);
overflow-wrap: anywhere;
}
.compact-list-item {
padding: 12px;
}
details.panel summary {
cursor: pointer;
list-style: none;
@@ -470,7 +632,8 @@ details.panel summary::-webkit-details-marker {
.split-layout,
.detail-grid,
.grid-two {
.grid-two,
.trace-detail-grid {
grid-template-columns: 1fr;
}
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import {
Activity,
Archive,
Bot,
FileSearch,
Files,
Home,
@@ -23,6 +24,7 @@ const mainNav = [
const adminNav = [
{ href: "/admin", label: "管理区", icon: Settings },
{ href: "/admin/ai-traces", label: "AI Trace", icon: Bot },
{ href: "/health", label: "系统健康", icon: Activity }
];
@@ -91,6 +93,7 @@ function resolveTitle(pathname: string): string {
if (pathname.startsWith("/contracts")) return "合同主流程";
if (pathname.startsWith("/search")) return "统一搜索";
if (pathname.startsWith("/files")) return "合同仓库";
if (pathname.startsWith("/admin/ai-traces")) return "AI 调用 Trace";
if (pathname.startsWith("/admin")) return "管理区";
if (pathname.startsWith("/health")) return "系统健康";
return "工作台";