diff --git a/src/app/page.tsx b/src/app/page.tsx index 5c48e55..4e62655 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { ArticleInputForm, type ArticleInputPayload, } from "../components/article-input-form"; +import { ArchitectureObserverPanel } from "../components/architecture/architecture-observer-panel"; import { FactCardEditor } from "../components/fact-card-editor"; import { OptimizedPreview } from "../components/optimized-preview"; import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel"; @@ -47,7 +48,7 @@ interface TimingSummary { } export default function Home() { - const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo"); + const [activeTab, setActiveTab] = useState<"geo" | "copy" | "architecture">("geo"); const [input, setInput] = useState(initialInput); const [jobId, setJobId] = useState(null); const [factCard, setFactCard] = useState(null); @@ -61,6 +62,7 @@ export default function Home() { const [elapsedSeconds, setElapsedSeconds] = useState(0); const [lastTiming, setLastTiming] = useState(null); const [streamActivity, setStreamActivity] = useState(""); + const [architectureEvents, setArchitectureEvents] = useState([]); useEffect(() => { if (!busyAction) return; @@ -84,6 +86,7 @@ export default function Home() { setOptimizedArticle(null); setQaReport(null); setStreamActivity("正在创建任务"); + setArchitectureEvents([]); try { const payload: ArticleInputPayload & { @@ -116,6 +119,9 @@ export default function Home() { } function handleStreamEvent(event: OptimizationStreamEvent) { + setArchitectureEvents((current) => event.type === "job_created" + ? [event] + : [...current, event]); switch (event.type) { case "job_created": setJobId(event.job.id); @@ -157,6 +163,12 @@ export default function Home() { : "优化完成。", ); break; + case "llm_call_started": + case "llm_call_responded": + case "llm_call_validated": + case "llm_call_failed": + case "trace_warning": + break; case "failed": setStreamActivity("优化失败"); throw new Error(event.error); @@ -200,6 +212,13 @@ export default function Home() { > 普通文案优化 + {activeTab === "geo" ? ( <> @@ -237,10 +256,16 @@ export default function Home() { /> - ) : ( + ) : activeTab === "copy" ? ( + ) : ( + )} ); diff --git a/src/components/architecture/__tests__/trace-state.test.ts b/src/components/architecture/__tests__/trace-state.test.ts index 1487990..b0ad102 100644 --- a/src/components/architecture/__tests__/trace-state.test.ts +++ b/src/components/architecture/__tests__/trace-state.test.ts @@ -6,6 +6,7 @@ import type { LlmTraceRun, } from "../../../lib/llm/trace-types"; import { + applyLiveOptimizationEvent, applyLiveTraceEvent, deriveArchitectureNodes, formatCallLabel, @@ -144,4 +145,49 @@ describe("architecture trace state", () => { }); expect(manifest.calls[0].status).toBe("validated"); }); + + it("uses workflow events to finish the run and retain QA business status", () => { + const withQa = applyLiveOptimizationEvent( + { run, calls: [call({ business_status: null })] }, + { + type: "qa_ready", + job_id: "job_1", + qa_report: { overall_status: "fail", checks: [] } as never, + }, + ); + const completed = applyLiveOptimizationEvent(withQa, { + type: "final_ready", + job_id: "job_1", + optimized_article: {} as never, + qa_report: { overall_status: "fail", checks: [] } as never, + export_paths: {}, + }); + + expect(withQa.calls[0].business_status).toBe("fail"); + expect(completed.run).toMatchObject({ + status: "completed", + current_stage: "final", + }); + expect(deriveArchitectureNodes(completed.run, completed.calls).final.status) + .toBe("completed"); + }); + + it("uses workflow failure events to mark the matching architecture stage", () => { + const failed = applyLiveOptimizationEvent( + { run: { ...run, current_stage: "draft" }, calls: [] }, + { + type: "failed", + job_id: "job_1", + stage: "qa", + error: "质量检查失败", + }, + ); + + expect(failed.run).toMatchObject({ + status: "failed", + current_stage: "qa", + error_stage: "qa", + error_summary: "质量检查失败", + }); + }); }); diff --git a/src/components/architecture/architecture-observer-panel.tsx b/src/components/architecture/architecture-observer-panel.tsx index 2423c38..4e2edae 100644 --- a/src/components/architecture/architecture-observer-panel.tsx +++ b/src/components/architecture/architecture-observer-panel.tsx @@ -4,13 +4,13 @@ import { useEffect, useMemo, useState } from "react"; import type { LlmTraceManifest, - LlmTraceStreamEvent, } from "../../lib/llm/trace-types"; +import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events"; import { getJobTrace, getLatestTrace } from "./api-client"; import { ArchitectureFlow } from "./architecture-flow"; import { LlmCallDetail } from "./llm-call-detail"; import { - applyLiveTraceEvent, + applyLiveOptimizationEvent, deriveArchitectureNodes, formatCallLabel, formatNodeStatus, @@ -19,7 +19,7 @@ import { interface ArchitectureObserverPanelProps { apiAccessKey: string; currentJobId: string | null; - liveEvents: LlmTraceStreamEvent[]; + liveEvents: OptimizationStreamEvent[]; } interface ManifestLoadState { @@ -90,7 +90,7 @@ export function ArchitectureObserverPanel({ const manifest = useMemo(() => { if (loadState?.key !== loadKey || !loadState.manifest) return null; - return liveEvents.reduce(applyLiveTraceEvent, loadState.manifest); + return liveEvents.reduce(applyLiveOptimizationEvent, loadState.manifest); }, [liveEvents, loadKey, loadState]); if (loadState?.key !== loadKey) { diff --git a/src/components/architecture/trace-state.ts b/src/components/architecture/trace-state.ts index c0a26c2..436f4ae 100644 --- a/src/components/architecture/trace-state.ts +++ b/src/components/architecture/trace-state.ts @@ -5,6 +5,7 @@ import type { LlmTraceStreamEvent, LlmTraceWorkflowStage, } from "../../lib/llm/trace-types"; +import type { OptimizationStreamEvent } from "../../lib/workflow/stream-events"; export type ArchitectureNodeStatus = | "waiting" @@ -183,6 +184,87 @@ export function applyLiveTraceEvent( }; } +function isTraceEvent( + event: OptimizationStreamEvent, +): event is LlmTraceStreamEvent { + return event.type === "llm_call_started" + || event.type === "llm_call_responded" + || event.type === "llm_call_validated" + || event.type === "llm_call_failed" + || event.type === "trace_warning"; +} + +function workflowStageForEvent( + event: OptimizationStreamEvent, +): LlmTraceWorkflowStage | null { + if (event.type === "fact_card_ready") return "fact_card"; + if (event.type === "draft_started" || event.type === "draft_ready") return "draft"; + if (event.type === "qa_started" || event.type === "qa_ready") return "qa"; + if (event.type === "rewrite_started" || event.type === "rewrite_ready") return "rewrite"; + if (event.type === "final_ready") return "final"; + if (event.type === "failed") { + return event.stage === "job" ? "input" : event.stage; + } + return null; +} + +export function applyLiveOptimizationEvent( + state: LlmTraceManifest, + event: OptimizationStreamEvent, +): LlmTraceManifest { + if (isTraceEvent(event)) return applyLiveTraceEvent(state, event); + if (event.type === "job_created") return state; + if (event.job_id !== state.run.job_id) return state; + + const now = new Date().toISOString(); + if (event.type === "qa_ready") { + const qaCalls = state.calls.filter((call) => call.task === "quality_inspector"); + const latestQaCall = qaCalls.at(-1); + return { + run: { ...state.run, current_stage: "qa", updated_at: now }, + calls: latestQaCall + ? updateCall(state.calls, latestQaCall.call_id, (call) => ({ + ...call, + business_status: event.qa_report.overall_status, + })) + : state.calls, + }; + } + + const stage = workflowStageForEvent(event); + if (!stage) return state; + if (event.type === "final_ready") { + return { + ...state, + run: { + ...state.run, + status: "completed", + current_stage: "final", + finished_at: now, + updated_at: now, + }, + }; + } + if (event.type === "failed") { + return { + ...state, + run: { + ...state.run, + status: "failed", + current_stage: stage, + error_stage: event.stage, + error_summary: event.error, + finished_at: now, + updated_at: now, + }, + }; + } + return { + ...state, + run: { ...state.run, current_stage: stage, updated_at: now }, + }; +} + function callDetail(call: LlmTraceCallPublic) { if (call.status === "failed" || call.schema_valid === false) { if (call.error_type === "provider") return "模型服务调用失败"; diff --git a/tests/e2e/architecture-observer.spec.ts b/tests/e2e/architecture-observer.spec.ts new file mode 100644 index 0000000..64282b1 --- /dev/null +++ b/tests/e2e/architecture-observer.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from "@playwright/test"; + +const jobId = "job_architecture"; +const callId = "llmcall_draft"; + +const run = { + job_id: jobId, + case_id: "case_architecture", + status: "completed", + current_stage: "final", + trace_completeness: "complete", + error_stage: null, + error_summary: null, + started_at: "2026-07-16T00:00:00.000Z", + finished_at: "2026-07-16T00:00:03.000Z", + updated_at: "2026-07-16T00:00:03.000Z", +}; + +const call = { + call_id: callId, + job_id: jobId, + sequence: 2, + task: "article_optimizer", + workflow_stage: "draft", + rewrite_round: null, + provider: "deepseek", + model: "deepseek-chat", + status: "validated", + token_usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 }, + schema_name: "optimizedArticleSchema", + schema_valid: true, + validation_issues: [], + business_status: null, + duration_ms: 1600, + started_at: "2026-07-16T00:00:01.000Z", + responded_at: "2026-07-16T00:00:02.500Z", + validated_at: "2026-07-16T00:00:02.600Z", + failed_at: null, + error_type: null, + error_summary: null, + request_available: true, + response_available: true, +}; + +const article = { + job_id: jobId, + revision: 1, + title: "可观测的 GEO 优化稿", + summary: "展示真实后台调用。", + body_markdown: "## 优化结果\n正文内容。", + image_suggestions: [], + changed_sections: ["title", "body"], + requires_user_confirmation: [], +}; + +const qaReport = { + job_id: jobId, + revision: 1, + overall_status: "pass", + checks: [], +}; + +test("后台架构标签按需展示完整 LLM 请求与响应", async ({ page }) => { + const payloadReads = { request: 0, response: 0 }; + const streamEvents = [ + { type: "job_created", job: { id: jobId } }, + { + type: "llm_call_started", + job_id: jobId, + call_id: callId, + sequence: 2, + task: "article_optimizer", + workflow_stage: "draft", + rewrite_round: null, + provider: "deepseek", + model: "deepseek-chat", + started_at: call.started_at, + request_available: true, + }, + { + type: "llm_call_responded", + job_id: jobId, + call_id: callId, + duration_ms: call.duration_ms, + token_usage: call.token_usage, + responded_at: call.responded_at, + response_available: true, + }, + { + type: "llm_call_validated", + job_id: jobId, + call_id: callId, + schema_name: call.schema_name, + schema_valid: true, + validation_issues: [], + validated_at: call.validated_at, + }, + { + type: "final_ready", + job_id: jobId, + optimized_article: article, + qa_report: qaReport, + export_paths: { + markdown: `/api/jobs/${jobId}/exports/optimized.md`, + docx: `/api/jobs/${jobId}/exports/optimized.docx`, + qa_report: `/api/jobs/${jobId}/exports/qa_report.json`, + }, + }, + ]; + + await page.route("**/api/jobs/optimize-stream", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/x-ndjson; charset=utf-8", + body: `${streamEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + }); + }); + await page.route(`**/api/jobs/${jobId}/llm-trace`, async (route) => { + await route.fulfill({ json: { run, calls: [call] } }); + }); + await page.route("**/api/llm-traces/latest", async (route) => { + await route.fulfill({ json: { run, calls: [call] } }); + }); + await page.route(`**/api/jobs/${jobId}/llm-trace/${callId}/request`, async (route) => { + payloadReads.request += 1; + await route.fulfill({ + json: { + model: "deepseek-chat", + messages: [ + { role: "system", content: "你是 GEO 文章优化器。" }, + { role: "user", content: "优化这篇原始文章。" }, + ], + response_format: { type: "json_object" }, + temperature: 0.2, + }, + }); + }); + await page.route(`**/api/jobs/${jobId}/llm-trace/${callId}/response`, async (route) => { + payloadReads.response += 1; + await route.fulfill({ + json: { + id: "chatcmpl_observer", + choices: [{ message: { role: "assistant", content: JSON.stringify(article) } }], + usage: call.token_usage, + }, + }); + }); + + await page.goto("/"); + await page.getByLabel("访问密钥").fill("local-dev-key"); + await page.getByLabel("文章内容").fill("这是一篇需要优化的原始文章。"); + await page.getByRole("button", { name: "开始优化" }).click(); + await page.getByRole("button", { name: "后台架构" }).click(); + + await expect(page.getByLabel("文章优化后台架构")).toBeVisible(); + await expect(page.locator(".llm-call-list code", { hasText: "article_optimizer" })) + .toBeVisible(); + await expect(page.locator('[data-node="final"]')).toContainText("已完成"); + + await page.getByRole("tab", { name: "请求" }).click(); + await expect(page.locator(".llm-json-view")) + .toContainText("开启技术详情后按需读取完整正文。"); + expect(payloadReads.request).toBe(0); + expect(payloadReads.response).toBe(0); + + await page.getByLabel("技术详情").check(); + await expect(page.locator(".llm-json-view")).toContainText("messages"); + await expect(page.locator(".llm-json-view")).toContainText("你是 GEO 文章优化器。"); + expect(payloadReads.request).toBe(1); + + await page.getByRole("tab", { name: "响应" }).click(); + await expect(page.locator(".llm-json-view")).toContainText("choices"); + await expect(page.locator(".llm-json-view")).toContainText("chatcmpl_observer"); + expect(payloadReads.response).toBe(1); + + await page.getByRole("button", { name: "GEO 文章优化" }).click(); + await expect(page.getByText("优化完成。", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible(); +}); diff --git a/tests/e2e/mvp.spec.ts b/tests/e2e/mvp.spec.ts index 4927d4f..7966e0a 100644 --- a/tests/e2e/mvp.spec.ts +++ b/tests/e2e/mvp.spec.ts @@ -6,29 +6,14 @@ test("中文界面可以生成优化文章和导出链接", async ({ page }) => await page.goto("/"); await page.getByLabel("访问密钥").fill("local-dev-key"); - await page.getByLabel("标题").fill("Example Technology Co., Ltd. GEO 指南"); await page - .getByLabel("正文") + .getByLabel("文章内容") .fill( "Example Technology Co., Ltd. has 8 years of GEO optimization experience. Example GEO 帮助市场团队优化内容结构。", ); await page.getByLabel("图片描述或图片链接").fill("产品仪表盘截图"); await page.getByLabel("用户要求").fill("保持事实准确,语气自然。"); - await page.getByRole("button", { name: "分析文章" }).click(); - await expect(page.getByText("候选事实卡已生成")).toBeVisible({ - timeout: 70_000, - }); - - const confirmUncertainItemButtons = page.getByRole("button", { - name: "采纳为核心事实", - }); - while ((await confirmUncertainItemButtons.count()) > 0) { - await confirmUncertainItemButtons.first().click(); - } - await page.getByRole("button", { name: "确认事实卡" }).click(); - await expect(page.getByText("事实卡已确认。")).toBeVisible(); - await page.getByRole("button", { name: "开始优化" }).click(); await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible({ timeout: 120_000, diff --git a/tests/e2e/sample-flow/page-flow.ts b/tests/e2e/sample-flow/page-flow.ts index 2298246..4aab642 100644 --- a/tests/e2e/sample-flow/page-flow.ts +++ b/tests/e2e/sample-flow/page-flow.ts @@ -37,6 +37,16 @@ interface QaReportJson { checks?: Array<{ rule_id?: string; status?: "pass" | "warn" | "fail" }>; } +interface TraceManifestJson { + run?: { job_id?: string; status?: string; trace_completeness?: string }; + calls?: Array<{ + task?: string; + status?: string; + request_available?: boolean; + response_available?: boolean; + }>; +} + export async function runSamplePageFlow({ page, request, @@ -87,17 +97,29 @@ export async function runSamplePageFlow({ jobId, exportsDir, }); + const traceResult = await validateLlmTrace({ + request, + baseURL, + apiAccessKey, + jobId, + }); const qa = readQaReport(exportsDir); const finalScreenshot = join(sampleDir, "final.png"); await page.screenshot({ path: finalScreenshot, fullPage: true }); const failedExports = exportResults.filter((result) => result.status === "failed"); + const failureMessage = [ + ...failedExports.map( + (result) => `${result.fileName}: ${result.error ?? result.statusCode}`, + ), + ...(traceResult.error ? [`LLM trace: ${traceResult.error}`] : []), + ].join("; "); return { file: sample.filePath, name: sample.name, slug: sample.slug, - status: failedExports.length === 0 ? "passed" : "failed", + status: failedExports.length === 0 && !traceResult.error ? "passed" : "failed", duration_ms: Date.now() - startedAt, job_id: jobId, qa_status: qa.overall_status, @@ -108,18 +130,61 @@ export async function runSamplePageFlow({ exports: Object.fromEntries( exportResults.map((result) => [result.fileName, result.status]), ), - llm_tasks: [], - failure_category: failedExports.length > 0 ? "export_failed" : undefined, - failure_message: - failedExports - .map((result) => `${result.fileName}: ${result.error ?? result.statusCode}`) - .join("; ") || undefined, + llm_tasks: traceResult.tasks, + failure_category: failedExports.length > 0 + ? "export_failed" + : traceResult.error + ? "llm_failed" + : undefined, + failure_message: failureMessage || undefined, artifacts: { final_screenshot: finalScreenshot, }, }; } +async function validateLlmTrace({ + request, + baseURL, + apiAccessKey, + jobId, +}: { + request: APIRequestContext; + baseURL: string; + apiAccessKey: string; + jobId: string; +}) { + const headers: Record | undefined = apiAccessKey + ? { "x-api-key": apiAccessKey } + : undefined; + const response = await request.get( + `${baseURL}/api/jobs/${jobId}/llm-trace`, + { headers }, + ); + if (!response.ok()) { + return { tasks: [], error: `metadata endpoint returned ${response.status()}` }; + } + + const manifest = await response.json() as TraceManifestJson; + const calls = manifest.calls ?? []; + const tasks = calls + .map((call) => call.task) + .filter((task): task is string => Boolean(task)); + if (manifest.run?.job_id !== jobId) { + return { tasks, error: "run job_id does not match completed job" }; + } + if (manifest.run.status !== "completed") { + return { tasks, error: `run status is ${manifest.run.status ?? "missing"}` }; + } + if (calls.length === 0) { + return { tasks, error: "no LLM calls were recorded" }; + } + if (calls.some((call) => !call.status || call.request_available !== true)) { + return { tasks, error: "call metadata is incomplete" }; + } + return { tasks, error: undefined }; +} + async function fillFirstAvailable(page: Page, labels: string[], value: string) { for (const label of labels) { const locator = page.getByLabel(label);