feat: add ai trace admin console
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
+37
@@ -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 "工作台";
|
||||
|
||||
Reference in New Issue
Block a user