feat: add ai trace admin console
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# V2 AI Trace 管理后台闭环记录
|
||||
|
||||
日期:2026-06-23
|
||||
|
||||
## 本轮目标
|
||||
|
||||
让管理员能在 V2 管理区直接查看 AI 调用 Trace,定位每次法律问答或后续 AI 能力调用的模型、Prompt、知识命中、token、费用、错误和耗时。
|
||||
|
||||
## 已落地内容
|
||||
|
||||
1. AI run 审计字段增强。
|
||||
- `AiRunAudit.prompt_preview`
|
||||
- `ai_run_audits.prompt_preview`
|
||||
- 新增迁移 `20260623_0006_add_ai_run_prompt_preview.py`
|
||||
|
||||
2. 法律问答调用写入 Prompt 摘要。
|
||||
- 每次 `/api/v1/legal/qa` 会把渲染后的 Prompt 截取前 4000 字符写入审计。
|
||||
- 保留 provider、model、provider_status、provider_error、token、费用、耗时、检索来源等既有 trace 字段。
|
||||
|
||||
3. 契约和 SDK 同步。
|
||||
- OpenAPI `AiRun` 新增 `prompt_preview`。
|
||||
- Python SDK `AiRunAudit` 新增 `prompt_preview`。
|
||||
|
||||
4. 前端管理区新增 AI Trace 可视化。
|
||||
- 新增 `/admin/ai-traces`。
|
||||
- 管理侧边栏新增 `AI Trace`。
|
||||
- 管理首页新增 `AI 调用 Trace` 入口。
|
||||
- 页面支持:
|
||||
- 最近调用数、总 token、平均耗时、异常调用数。
|
||||
- 按状态筛选。
|
||||
- 按问题、模型、错误、法条来源搜索。
|
||||
- 展开查看 Prompt、回答、知识命中、token、费用、错误、Trace ID。
|
||||
|
||||
5. 前端同源代理。
|
||||
- 新增 `/api/ai-platform/audit/ai-runs`。
|
||||
- 服务端读取:
|
||||
- `AI_PLATFORM_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL`
|
||||
- 默认 `http://localhost:8101`
|
||||
|
||||
## 验收重点
|
||||
|
||||
- AI Platform 执行一次法律问答后,`GET /api/v1/audit/ai-runs` 返回 `prompt_preview`。
|
||||
- 合同 Web 打开 `/admin/ai-traces` 能看到列表、筛选、指标和详情。
|
||||
- AI Platform 不可用时,页面显示清晰错误状态,不影响管理区其他页面。
|
||||
|
||||
## 建议下一步
|
||||
|
||||
进入 AI 质量评测 MVP:
|
||||
|
||||
- 把一次回答是否带来源、是否引用足够、是否出现 provider fallback、是否 token/耗时异常形成质量标签。
|
||||
- 在 AI Trace 页给每次调用加“质量判定”和“可复盘原因”。
|
||||
@@ -1178,6 +1178,9 @@ components:
|
||||
provider_error:
|
||||
type: string
|
||||
nullable: true
|
||||
prompt_preview:
|
||||
type: string
|
||||
nullable: true
|
||||
question:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""add ai run prompt preview
|
||||
|
||||
Revision ID: 20260623_0006
|
||||
Revises: 20260622_0005
|
||||
Create Date: 2026-06-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260623_0006"
|
||||
down_revision: Union[str, None] = "20260622_0005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ai_run_audits", sa.Column("prompt_preview", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_run_audits", "prompt_preview")
|
||||
@@ -259,6 +259,7 @@ def create_app(
|
||||
model=provider_response.model or (provider_config.model if provider_config else None),
|
||||
provider_status=provider_response.status,
|
||||
provider_error=provider_response.error_message,
|
||||
prompt_preview=_preview_text(prompt),
|
||||
question=request.question,
|
||||
answer=response.answer,
|
||||
citations_count=len(response.citations),
|
||||
@@ -282,6 +283,12 @@ def create_app(
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _preview_text(value: str, *, limit: int = 4000) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return f"{value[:limit]}..."
|
||||
|
||||
|
||||
def _search_citation_documents(
|
||||
repository: AiPlatformRepository,
|
||||
request: LegalQaRequest,
|
||||
|
||||
@@ -111,6 +111,7 @@ class AiRunAuditModel(Base):
|
||||
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
provider_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
provider_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
prompt_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
question: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
answer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
citations_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@@ -199,6 +199,7 @@ class AiRunAudit(BaseModel):
|
||||
model: str | None = None
|
||||
provider_status: str | None = None
|
||||
provider_error: str | None = None
|
||||
prompt_preview: str | None = None
|
||||
question: str | None = None
|
||||
answer: str | None = None
|
||||
citations_count: int = 0
|
||||
@@ -946,6 +947,7 @@ class SqlAlchemyAiPlatformRepository:
|
||||
model=audit.model,
|
||||
provider_status=audit.provider_status,
|
||||
provider_error=audit.provider_error,
|
||||
prompt_preview=audit.prompt_preview,
|
||||
question=audit.question,
|
||||
answer=audit.answer,
|
||||
citations_count=audit.citations_count,
|
||||
@@ -1168,12 +1170,13 @@ def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit:
|
||||
operation=row.operation,
|
||||
provider_config_id=row.provider_config_id,
|
||||
prompt_template_id=row.prompt_template_id,
|
||||
provider=row.provider,
|
||||
model=row.model,
|
||||
provider_status=row.provider_status,
|
||||
provider_error=row.provider_error,
|
||||
question=row.question,
|
||||
answer=row.answer,
|
||||
provider=row.provider,
|
||||
model=row.model,
|
||||
provider_status=row.provider_status,
|
||||
provider_error=row.provider_error,
|
||||
prompt_preview=row.prompt_preview,
|
||||
question=row.question,
|
||||
answer=row.answer,
|
||||
citations_count=row.citations_count,
|
||||
retrieval_query=row.retrieval_query,
|
||||
retrieval_strategy=row.retrieval_strategy,
|
||||
|
||||
@@ -320,6 +320,7 @@ def test_legal_qa_uses_chunk_search_and_records_audit() -> None:
|
||||
assert audits[0]["operation"] == "legal_qa"
|
||||
assert audits[0]["citations_count"] >= 1
|
||||
assert audits[0]["prompt_template_id"] is not None
|
||||
assert "How should deposit refund be handled?" in audits[0]["prompt_preview"]
|
||||
assert audits[0]["retrieval_query"] == "How should deposit refund be handled?"
|
||||
assert audits[0]["retrieval_strategy"] == "hybrid"
|
||||
assert audits[0]["retrieved_chunk_ids"]
|
||||
@@ -377,6 +378,7 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
provider="deepseek",
|
||||
model="deepseek-chat",
|
||||
provider_status="succeeded",
|
||||
prompt_preview="Search evidence first, then answer. Question: How should lease deposit refund be handled?",
|
||||
question="How should lease deposit refund be handled?",
|
||||
answer="The contract should clearly state deposit refund conditions.",
|
||||
citations_count=1,
|
||||
@@ -428,6 +430,8 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
assert audit.provider == "deepseek"
|
||||
assert audit.model == "deepseek-chat"
|
||||
assert audit.provider_status == "succeeded"
|
||||
assert audit.prompt_preview is not None
|
||||
assert "lease deposit" in audit.prompt_preview
|
||||
assert audit.prompt_tokens == 12
|
||||
assert audit.completion_tokens == 8
|
||||
assert audit.total_tokens == 20
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_legal_qa_uses_provider_answer_when_enabled(monkeypatch) -> None:
|
||||
assert audit["model"] == "legal-pro"
|
||||
assert audit["provider_status"] == "succeeded"
|
||||
assert audit["provider_error"] is None
|
||||
assert "试用期如何约定" in audit["prompt_preview"]
|
||||
assert audit["prompt_tokens"] == 100
|
||||
assert audit["completion_tokens"] == 50
|
||||
assert audit["total_tokens"] == 150
|
||||
@@ -113,6 +114,7 @@ def test_provider_failure_falls_back_to_rule_answer_and_audit_status() -> None:
|
||||
assert audit["model"] == "legal-pro"
|
||||
assert audit["provider_status"] == "failed"
|
||||
assert audit["provider_error"] == "Missing provider API key."
|
||||
assert "试用期如何约定" in audit["prompt_preview"]
|
||||
assert audit["total_tokens"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ class AiRunAudit(BaseModel):
|
||||
model: str | None = None
|
||||
provider_status: str | None = None
|
||||
provider_error: str | None = None
|
||||
prompt_preview: str | None = None
|
||||
question: str | None = None
|
||||
answer: str | None = None
|
||||
citations_count: int = 0
|
||||
|
||||
@@ -353,6 +353,7 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
"model": "deepseek-chat",
|
||||
"provider_status": "succeeded",
|
||||
"provider_error": None,
|
||||
"prompt_preview": "Search evidence first, then answer. Question: How should lease deposit refund be handled?",
|
||||
"question": "How should lease deposit refund be handled?",
|
||||
"answer": "The contract should clearly state deposit refund conditions.",
|
||||
"citations_count": 1,
|
||||
@@ -443,6 +444,8 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert audit.model == "deepseek-chat"
|
||||
assert audit.provider_status == "succeeded"
|
||||
assert audit.provider_error is None
|
||||
assert audit.prompt_preview is not None
|
||||
assert "lease deposit" in audit.prompt_preview
|
||||
assert audit.prompt_tokens == 12
|
||||
assert audit.completion_tokens == 8
|
||||
assert audit.total_tokens == 20
|
||||
|
||||
@@ -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