diff --git a/src/app/globals.css b/src/app/globals.css index 4bb4346..9fc7ec4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/architecture/__tests__/trace-state.test.ts b/src/components/architecture/__tests__/trace-state.test.ts index f6f81ce..1487990 100644 --- a/src/components/architecture/__tests__/trace-state.test.ts +++ b/src/components/architecture/__tests__/trace-state.test.ts @@ -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" }), diff --git a/src/components/architecture/architecture-flow.tsx b/src/components/architecture/architecture-flow.tsx new file mode 100644 index 0000000..1265392 --- /dev/null +++ b/src/components/architecture/architecture-flow.tsx @@ -0,0 +1,48 @@ +import { + formatNodeStatus, + type ArchitectureNodeView, +} from "./trace-state"; + +export function ArchitectureFlow({ + nodes, +}: { + nodes: Record; +}) { + const order: ArchitectureNodeView["id"][] = [ + "input", + "fact_card", + "draft", + "qa", + "rewrite", + "final", + ]; + + return ( +
+
+ {order.map((id, index) => { + const node = nodes[id]; + return ( +
+
+ {index + 1} + {node.label} + {formatNodeStatus(node.status)} + {node.detail} +
+ {index < order.length - 1 && ( + + )} +
+ ); + })} +
+

+ 质量检查未通过时,定向修复后返回复检,最多两轮。 +

+
+ ); +} diff --git a/src/components/architecture/architecture-observer-panel.tsx b/src/components/architecture/architecture-observer-panel.tsx new file mode 100644 index 0000000..2423c38 --- /dev/null +++ b/src/components/architecture/architecture-observer-panel.tsx @@ -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(null); + const [selectedCallId, setSelectedCallId] = useState(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
正在读取后台架构…
; + } + if (loadState.status === "error") { + return ( +
+ 无法读取后台追踪 +

{loadState.error}

+
+ ); + } + if (loadState.status === "empty" || !manifest) { + return ( +
+ 暂无后台追踪。请先在“GEO 文章优化”标签运行一次优化。 +
+ ); + } + + 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 ( +
+
+
+ + {isCurrentTask ? "当前任务" : "最近一次任务"} + + {manifest.run.job_id} + + {latestCall + ? `${latestCall.provider} · ${latestCall.model}` + : "尚未发起 LLM 调用"} + +
+
+ {runStatusLabel(manifest.run.status)} + {formatNodeStatus(nodes[manifest.run.current_stage === "unknown" ? "input" : manifest.run.current_stage].status)} + +
+
+ + {manifest.run.trace_completeness === "incomplete" && ( +

+ 追踪记录不完整:已保存的数据仍可查看,但部分阶段或正文可能缺失。 +

+ )} + + + +
+ + + {selectedCall ? ( + + ) : ( +
请选择一次 LLM 调用。
+ )} +
+
+ ); +} diff --git a/src/components/architecture/llm-call-detail.tsx b/src/components/architecture/llm-call-detail.tsx new file mode 100644 index 0000000..4bf025e --- /dev/null +++ b/src/components/architecture/llm-call-detail.tsx @@ -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("validation"); + const [loadedPayload, setLoadedPayload] = useState(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 ( +
+
+
+ {call.task} + {call.provider} · {call.model} +
+ + {call.status} + +
+
+ {(["request", "response", "validation"] as const).map((value) => ( + + ))} +
+
{JSON.stringify(displayValue, null, 2)}
+
+ ); +} diff --git a/src/components/architecture/trace-state.ts b/src/components/architecture/trace-state.ts index a692416..c0a26c2 100644 --- a/src/components/architecture/trace-state.ts +++ b/src/components/architecture/trace-state.ts @@ -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> = [ { id: "input", label: "输入归一化" }, { id: "fact_card", label: "事实提取" },