新增LLM后台架构观测界面

This commit is contained in:
czj
2026-07-16 13:08:07 +08:00
parent cd661b99c7
commit adbe9d129d
6 changed files with 675 additions and 1 deletions
+283 -1
View File
@@ -222,6 +222,278 @@ h3 {
font-weight: 800;
}
.architecture-observer {
min-width: 0;
}
.architecture-task-strip {
align-items: center;
background: #172033;
border-radius: 8px;
color: #ffffff;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 0.85rem 1rem;
}
.architecture-task-strip > div:first-child {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.architecture-task-strip strong,
.architecture-task-strip small {
overflow: hidden;
text-overflow: ellipsis;
}
.architecture-task-strip small,
.architecture-eyebrow {
color: #cbd3df;
}
.architecture-eyebrow {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.architecture-task-meta {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.architecture-task-meta > span {
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
font-size: 0.78rem;
padding: 0.35rem 0.6rem;
}
.technical-detail-toggle {
align-items: center;
background: #ffffff;
border-radius: 6px;
color: #172033;
cursor: pointer;
display: flex;
gap: 0.45rem;
padding: 0.4rem 0.6rem;
}
.technical-detail-toggle input {
accent-color: #2f6fdd;
height: 1rem;
margin: 0;
padding: 0;
width: 1rem;
}
.technical-detail-toggle span {
color: inherit;
font-size: 0.76rem;
}
.architecture-warning {
background: #fff7e6;
border: 1px solid #f2c879;
border-radius: 8px;
color: #78520a;
margin: 0;
padding: 0.75rem 0.9rem;
}
.architecture-flow-panel {
background: #ffffff;
border: 1px solid #dce2eb;
border-radius: 8px;
overflow-x: auto;
padding: 1rem;
}
.architecture-flow {
align-items: stretch;
display: flex;
min-width: 62rem;
}
.architecture-flow-step {
align-items: center;
display: flex;
flex: 1 0 0;
min-width: 0;
}
.architecture-node {
border: 1px solid #cbd3df;
border-radius: 8px;
display: grid;
flex: 1 0 0;
gap: 0.3rem;
min-height: 8.5rem;
min-width: 8.5rem;
padding: 0.75rem;
}
.architecture-node > span:not(.architecture-node-index),
.architecture-node small {
color: #586174;
font-size: 0.76rem;
}
.architecture-node-index {
align-items: center;
background: #eef1f6;
border-radius: 999px;
display: flex;
font-size: 0.72rem;
font-weight: 800;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
}
.architecture-node-running {
background: #eef5ff;
border-color: #2f6fdd;
box-shadow: 0 0 0 2px rgba(47, 111, 221, 0.1);
}
.architecture-node-completed {
background: #effaf4;
border-color: #55a47a;
}
.architecture-node-failed {
background: #fff1f1;
border-color: #d05858;
}
.architecture-node-skipped {
background: #f6f7f9;
border-style: dashed;
opacity: 0.72;
}
.architecture-arrow {
color: #8992a3;
flex: 0 0 1.4rem;
font-size: 1.1rem;
text-align: center;
}
.architecture-loop-label {
color: #586174;
font-size: 0.78rem;
margin: 0.75rem 0 0;
text-align: right;
}
.architecture-workspace {
display: grid;
gap: 1rem;
grid-template-columns: minmax(16rem, 0.7fr) minmax(0, 1.3fr);
}
.llm-call-list,
.llm-call-detail {
min-height: 26rem;
}
.llm-call-list {
align-content: start;
display: grid;
gap: 0.5rem;
}
.llm-call-list .panel-heading {
padding-bottom: 0.5rem;
}
.llm-call-list button {
background: #ffffff;
border-color: #dce2eb;
color: #172033;
display: grid;
gap: 0.25rem;
min-width: 0;
padding: 0.7rem;
text-align: left;
white-space: normal;
}
.llm-call-list button.selected {
border-color: #2f6fdd;
box-shadow: 0 0 0 2px rgba(47, 111, 221, 0.1);
}
.llm-call-list code,
.llm-call-list small {
color: #586174;
overflow-wrap: anywhere;
}
.llm-call-detail {
display: grid;
grid-template-rows: auto auto minmax(18rem, 1fr);
}
.llm-detail-heading,
.llm-detail-tabs {
align-items: center;
display: flex;
gap: 0.5rem;
}
.llm-detail-heading {
justify-content: space-between;
}
.llm-detail-heading > div {
display: grid;
gap: 0.25rem;
}
.llm-detail-heading small {
color: #586174;
}
.llm-detail-tabs {
border-bottom: 1px solid #dce2eb;
margin-top: 0.8rem;
}
.llm-detail-tabs button {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
color: #586174;
}
.llm-detail-tabs button.active-tab {
border-bottom-color: #2f6fdd;
color: #172033;
}
.llm-json-view {
background: #111827;
border-radius: 6px;
color: #dbeafe;
font-size: 0.78rem;
margin: 0.8rem 0 0;
overflow: auto;
padding: 1rem;
white-space: pre-wrap;
word-break: break-word;
}
.panel-actions {
align-items: center;
display: flex;
@@ -769,10 +1041,20 @@ h3 {
.calibration-metrics,
.case-toolbar,
.case-detail-grid,
.case-meta-grid {
.case-meta-grid,
.architecture-workspace {
grid-template-columns: 1fr;
}
.architecture-task-strip {
align-items: stretch;
flex-direction: column;
}
.architecture-task-meta {
justify-content: flex-start;
}
.case-row,
.case-row-heading {
grid-template-columns: 1fr;
@@ -8,6 +8,8 @@ import type {
import {
applyLiveTraceEvent,
deriveArchitectureNodes,
formatCallLabel,
formatNodeStatus,
} from "../trace-state";
const run: LlmTraceRun = {
@@ -55,6 +57,17 @@ function call(
}
describe("architecture trace state", () => {
it("formats every architecture node and call label in Chinese", () => {
expect(formatNodeStatus("running")).toBe("运行中");
expect(formatNodeStatus("failed")).toBe("失败");
expect(formatCallLabel(call({
sequence: 4,
task: "targeted_rewriter",
rewrite_round: 1,
duration_ms: 22600,
}))).toBe("4 · 定向修复第 1 轮 · 22.6 秒");
});
it("distinguishes QA business failure from schema failure", () => {
const nodes = deriveArchitectureNodes(run, [
call({ schema_valid: true, business_status: "fail" }),
@@ -0,0 +1,48 @@
import {
formatNodeStatus,
type ArchitectureNodeView,
} from "./trace-state";
export function ArchitectureFlow({
nodes,
}: {
nodes: Record<ArchitectureNodeView["id"], ArchitectureNodeView>;
}) {
const order: ArchitectureNodeView["id"][] = [
"input",
"fact_card",
"draft",
"qa",
"rewrite",
"final",
];
return (
<section className="architecture-flow-panel" aria-label="文章优化后台架构">
<div className="architecture-flow">
{order.map((id, index) => {
const node = nodes[id];
return (
<div className="architecture-flow-step" key={node.id}>
<article
className={`architecture-node architecture-node-${node.status}`}
data-node={node.id}
>
<span className="architecture-node-index">{index + 1}</span>
<strong>{node.label}</strong>
<span>{formatNodeStatus(node.status)}</span>
<small>{node.detail}</small>
</article>
{index < order.length - 1 && (
<span aria-hidden="true" className="architecture-arrow"></span>
)}
</div>
);
})}
</div>
<p className="architecture-loop-label">
</p>
</section>
);
}
@@ -0,0 +1,191 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type {
LlmTraceManifest,
LlmTraceStreamEvent,
} from "../../lib/llm/trace-types";
import { getJobTrace, getLatestTrace } from "./api-client";
import { ArchitectureFlow } from "./architecture-flow";
import { LlmCallDetail } from "./llm-call-detail";
import {
applyLiveTraceEvent,
deriveArchitectureNodes,
formatCallLabel,
formatNodeStatus,
} from "./trace-state";
interface ArchitectureObserverPanelProps {
apiAccessKey: string;
currentJobId: string | null;
liveEvents: LlmTraceStreamEvent[];
}
interface ManifestLoadState {
key: string;
status: "loaded" | "empty" | "error";
manifest: LlmTraceManifest | null;
error: string;
}
function loadError(error: unknown) {
return error instanceof Error ? error.message : "后台追踪加载失败";
}
function runStatusLabel(status: LlmTraceManifest["run"]["status"]) {
if (status === "running") return "运行中";
if (status === "completed") return "已完成";
if (status === "interrupted") return "已中断";
return "失败";
}
export function ArchitectureObserverPanel({
apiAccessKey,
currentJobId,
liveEvents,
}: ArchitectureObserverPanelProps) {
const [loadState, setLoadState] = useState<ManifestLoadState | null>(null);
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
const [technicalDetailsEnabled, setTechnicalDetailsEnabled] = useState(false);
const loadKey = `${currentJobId ?? "latest"}:${apiAccessKey}`;
useEffect(() => {
let current = true;
const request = currentJobId
? getJobTrace(currentJobId, apiAccessKey)
: getLatestTrace(apiAccessKey);
request
.then((result) => {
if (!current) return;
if (result.run === null) {
setLoadState({
key: loadKey,
status: "empty",
manifest: null,
error: "",
});
return;
}
setLoadState({
key: loadKey,
status: "loaded",
manifest: result,
error: "",
});
})
.catch((error) => {
if (!current) return;
setLoadState({
key: loadKey,
status: "error",
manifest: null,
error: loadError(error),
});
});
return () => {
current = false;
};
}, [apiAccessKey, currentJobId, loadKey]);
const manifest = useMemo(() => {
if (loadState?.key !== loadKey || !loadState.manifest) return null;
return liveEvents.reduce(applyLiveTraceEvent, loadState.manifest);
}, [liveEvents, loadKey, loadState]);
if (loadState?.key !== loadKey) {
return <section className="panel empty-panel"></section>;
}
if (loadState.status === "error") {
return (
<section className="panel empty-panel">
<strong></strong>
<p>{loadState.error}</p>
</section>
);
}
if (loadState.status === "empty" || !manifest) {
return (
<section className="panel empty-panel">
GEO
</section>
);
}
const nodes = deriveArchitectureNodes(manifest.run, manifest.calls);
const selectedCall = manifest.calls.find(
(call) => call.call_id === selectedCallId,
) ?? manifest.calls[0] ?? null;
const latestCall = manifest.calls.at(-1) ?? null;
const isCurrentTask = currentJobId === manifest.run.job_id;
return (
<section className="architecture-observer stack">
<header className="architecture-task-strip">
<div>
<span className="architecture-eyebrow">
{isCurrentTask ? "当前任务" : "最近一次任务"}
</span>
<strong>{manifest.run.job_id}</strong>
<small>
{latestCall
? `${latestCall.provider} · ${latestCall.model}`
: "尚未发起 LLM 调用"}
</small>
</div>
<div className="architecture-task-meta">
<span>{runStatusLabel(manifest.run.status)}</span>
<span>{formatNodeStatus(nodes[manifest.run.current_stage === "unknown" ? "input" : manifest.run.current_stage].status)}</span>
<label className="technical-detail-toggle">
<input
checked={technicalDetailsEnabled}
onChange={(event) => setTechnicalDetailsEnabled(event.target.checked)}
type="checkbox"
/>
<span></span>
</label>
</div>
</header>
{manifest.run.trace_completeness === "incomplete" && (
<p className="architecture-warning" role="status">
</p>
)}
<ArchitectureFlow nodes={nodes} />
<div className="architecture-workspace">
<aside className="panel llm-call-list">
<div className="panel-heading">LLM </div>
{manifest.calls.length === 0 ? (
<p className="empty-panel"> LLM </p>
) : manifest.calls.map((call) => (
<button
className={selectedCall?.call_id === call.call_id ? "selected" : undefined}
key={call.call_id}
onClick={() => setSelectedCallId(call.call_id)}
type="button"
>
<strong>{formatCallLabel(call)}</strong>
<code>{call.task}</code>
<small>{call.schema_valid === false ? "Schema 失败" : call.status}</small>
</button>
))}
</aside>
{selectedCall ? (
<LlmCallDetail
apiAccessKey={apiAccessKey}
call={selectedCall}
jobId={manifest.run.job_id}
key={selectedCall.call_id}
technicalDetailsEnabled={technicalDetailsEnabled}
/>
) : (
<section className="panel empty-panel"> LLM </section>
)}
</div>
</section>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import type { LlmTraceCallPublic } from "../../lib/llm/trace-types";
import { getTracePayload } from "./api-client";
type DetailTab = "request" | "response" | "validation";
interface LoadedPayload {
key: string;
value: unknown;
}
function detailTabLabel(tab: DetailTab) {
if (tab === "request") return "请求";
if (tab === "response") return "响应";
return "校验";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "追踪正文加载失败";
}
function validationView(call: LlmTraceCallPublic) {
return {
schema_name: call.schema_name,
schema_valid: call.schema_valid,
validation_issues: call.validation_issues,
business_status: call.business_status,
error_type: call.error_type,
error_summary: call.error_summary,
duration_ms: call.duration_ms,
token_usage: call.token_usage,
};
}
export function LlmCallDetail({
jobId,
call,
apiAccessKey,
technicalDetailsEnabled,
}: {
jobId: string;
call: LlmTraceCallPublic;
apiAccessKey: string;
technicalDetailsEnabled: boolean;
}) {
const [tab, setTab] = useState<DetailTab>("validation");
const [loadedPayload, setLoadedPayload] = useState<LoadedPayload | null>(null);
const payloadKey = `${call.call_id}:${tab}`;
useEffect(() => {
if (!technicalDetailsEnabled || tab === "validation") return;
let current = true;
getTracePayload(jobId, call.call_id, tab, apiAccessKey)
.then((value) => {
if (current) setLoadedPayload({ key: payloadKey, value });
})
.catch((error) => {
if (current) {
setLoadedPayload({
key: payloadKey,
value: { error: errorMessage(error) },
});
}
});
return () => {
current = false;
};
}, [apiAccessKey, call.call_id, jobId, payloadKey, tab, technicalDetailsEnabled]);
const displayValue = tab === "validation"
? validationView(call)
: !technicalDetailsEnabled
? { state: "locked", message: "开启技术详情后按需读取完整正文。" }
: loadedPayload?.key === payloadKey
? loadedPayload.value
: { state: "loading", message: "正在读取完整 JSON…" };
return (
<section className="panel llm-call-detail" aria-live="polite">
<div className="llm-detail-heading">
<div>
<strong>{call.task}</strong>
<small>{call.provider} · {call.model}</small>
</div>
<span className={`status-pill ${call.status === "failed" ? "fail" : "pass"}`}>
{call.status}
</span>
</div>
<div className="llm-detail-tabs" role="tablist" aria-label="LLM 调用详情">
{(["request", "response", "validation"] as const).map((value) => (
<button
aria-selected={tab === value}
className={tab === value ? "active-tab" : undefined}
key={value}
onClick={() => setTab(value)}
role="tab"
type="button"
>
{detailTabLabel(value)}
</button>
))}
</div>
<pre className="llm-json-view">{JSON.stringify(displayValue, null, 2)}</pre>
</section>
);
}
@@ -19,6 +19,37 @@ export interface ArchitectureNodeView {
detail: string;
}
export function formatNodeStatus(status: ArchitectureNodeStatus) {
return {
waiting: "等待中",
running: "运行中",
completed: "已完成",
failed: "失败",
}[status];
}
export function formatTaskName(call: LlmTraceCallPublic) {
if (call.task === "fact_extractor") return "事实提取";
if (call.task === "article_optimizer") return "生成优化稿";
if (call.task === "quality_inspector") {
return call.rewrite_round && call.rewrite_round > 0
? `质量复检第 ${call.rewrite_round}`
: "质量检查";
}
if (call.task === "targeted_rewriter") {
return `定向修复第 ${call.rewrite_round ?? 1}`;
}
if (call.task === "renwei_copy_optimizer") return "普通文案优化";
return "未知调用";
}
export function formatCallLabel(call: LlmTraceCallPublic) {
const duration = call.duration_ms == null
? "进行中"
: `${(call.duration_ms / 1000).toFixed(1)}`;
return `${call.sequence} · ${formatTaskName(call)} · ${duration}`;
}
const nodeDefinitions: Array<Pick<ArchitectureNodeView, "id" | "label">> = [
{ id: "input", label: "输入归一化" },
{ id: "fact_card", label: "事实提取" },