diff --git a/project-docs/V2-ai-trace-admin-console-2026-06-23.md b/project-docs/V2-ai-trace-admin-console-2026-06-23.md new file mode 100644 index 00000000..1be4beed --- /dev/null +++ b/project-docs/V2-ai-trace-admin-console-2026-06-23.md @@ -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 页给每次调用加“质量判定”和“可复盘原因”。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index 9b3708c7..84fa5ed7 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -1178,6 +1178,9 @@ components: provider_error: type: string nullable: true + prompt_preview: + type: string + nullable: true question: type: string nullable: true diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260623_0006_add_ai_run_prompt_preview.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260623_0006_add_ai_run_prompt_preview.py new file mode 100644 index 00000000..c4fc6973 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260623_0006_add_ai_run_prompt_preview.py @@ -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") diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index b6b21fa4..09c7c08b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -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, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py index 5b71fdfd..83070b5f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py @@ -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) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index fc13c1ce..5740ed22 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -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, diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index dac989e1..99f1dbe8 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -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 diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py index 72ce02f9..f5f69f69 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -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 diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 398448bd..0ef3f384 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -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 diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index d3795f6b..e89ab08d 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -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 diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-traces/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-traces/page.tsx new file mode 100644 index 00000000..15bdb94b --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-traces/page.tsx @@ -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 = { + succeeded: "成功", + fallback: "已回退", + failed: "失败", + running: "运行中", + pending: "等待中", + skipped: "已跳过", + cancelled: "已取消" +}; + +const statusTone: Record = { + 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({ 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 ( + <> +
+
+

AI 调用 Trace

+

查看模型、Prompt、知识命中、token、费用、错误和耗时。

+
+ +
+ +
+ } label="最近调用" value={String(runs.length)} /> + } label="总 token" value={formatInteger(metrics.totalTokens)} /> + } label="平均耗时" value={`${metrics.averageLatency} ms`} /> + } label="异常调用" value={String(metrics.failedCount)} /> +
+ +
+
+

调用列表

+ 预估费用 {formatCost(metrics.estimatedCost)} +
+
+
+
+ + setKeyword(event.target.value)} + /> +
+ +
+ + {state.status === "loading" &&
正在加载 AI 调用审计...
} + {state.status === "error" && ( +
+ 无法连接 AI Platform 审计接口 + {state.message} +
+ )} + {state.status === "ready" && filteredRuns.length === 0 && ( +
暂无匹配的 AI 调用记录。
+ )} + {state.status === "ready" && filteredRuns.length > 0 && ( +
+ {filteredRuns.map((run) => ( + + ))} +
+ )} +
+
+ + ); +} + +function Metric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { + return ( +
+
{icon}
+
{value}
+
{label}
+
+ ); +} + +function TraceItem({ run }: { run: AiRunAudit }) { + const statusClass = statusTone[run.status] ?? ""; + const providerStatusClass = statusTone[run.provider_status ?? ""] ?? ""; + + return ( +
+ +
+
+ {run.question || run.operation} + {statusLabels[run.status] ?? run.status} +
+
+ {formatDate(run.created_at)} + {run.provider || "unknown"} / {run.model || "unknown"} + {formatInteger(run.total_tokens ?? 0)} tokens + {run.latency_ms ?? 0} ms + {formatCost(run.estimated_cost ?? 0)} +
+
+
+ +
+
+

Prompt

+
{run.prompt_preview || "暂无 Prompt 摘要"}
+
+
+

回答

+
{run.answer || "暂无回答"}
+
+
+

知识命中

+ {(run.retrieved_sources ?? []).length === 0 ? ( +

未记录知识命中。

+ ) : ( +
+ {(run.retrieved_sources ?? []).map((source) => ( +
+
+ {source.title} + score {formatScore(source.score)} +
+ + {source.reference} · {source.locator || "无定位"} · {source.knowledge_base_id || "默认知识库"} + + + keyword {formatScore(source.keyword_score)} / vector {formatScore(source.vector_score)} / hybrid {formatScore(source.hybrid_score)} + +
+ ))} +
+ )} +
+
+

运行状态

+
+ 业务状态{statusLabels[run.status] ?? run.status} + Provider 状态{run.provider_status || "unknown"} + Prompt tokens{formatInteger(run.prompt_tokens ?? 0)} + Completion tokens{formatInteger(run.completion_tokens ?? 0)} + 总 tokens{formatInteger(run.total_tokens ?? 0)} + 预估费用{formatCost(run.estimated_cost ?? 0)} + 检索策略{run.retrieval_strategy || "unknown"} + Trace ID{run.id} +
+ {run.provider_error &&
{run.provider_error}
} +
+
+
+ ); +} + +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); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx index 0df88a02..d0907271 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx @@ -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() {

{section.title}

{section.description}

+ {section.href && ( + + 进入 + + )} ); diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/audit/ai-runs/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/audit/ai-runs/route.ts new file mode 100644 index 00000000..c1c9ce63 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/audit/ai-runs/route.ts @@ -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 } + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index d27ab18b..019bc6b9 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -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; } diff --git a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx index 7bad7b9d..5b52cf3e 100644 --- a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx @@ -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 "工作台";