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,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
@@ -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>Prompttoken</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 "工作台";