From 48f266ce322891a943fac5986ce480abfa653f83 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 09:42:01 +0800 Subject: [PATCH] docs: plan progress timing ui --- .../plans/2026-06-17-progress-timing.md | 885 ++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-progress-timing.md diff --git a/docs/superpowers/plans/2026-06-17-progress-timing.md b/docs/superpowers/plans/2026-06-17-progress-timing.md new file mode 100644 index 0000000..0b0f963 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-progress-timing.md @@ -0,0 +1,885 @@ +# Progress Timing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add visible progress, elapsed-time feedback, and backend timing summaries so users know long analysis and optimization requests are still working. + +**Architecture:** Keep the current synchronous request model and avoid SSE or background queues. The frontend shows deterministic running progress with elapsed seconds and a neutral "通常需要 10-40 秒" message while fetch requests are in flight; the backend returns real timing summaries after completion or failure. Timing is captured around existing workflow stages without changing LLM prompts or business logic. + +**Tech Stack:** Next.js App Router, React state/hooks, TypeScript, Vitest, existing fetch API routes, existing workflow modules. + +--- + +## File Structure + +- Modify `src/lib/workflow/orchestrator.ts` + - Add workflow timing types and record per-stage durations around article optimization, quality inspection, and rewrite rounds. + - Return timing summaries with the existing workflow result. +- Modify `src/app/api/jobs/route.ts` + - Measure `/api/jobs` fact-card extraction elapsed time and return a `timing` object for success and LLM failure responses. +- Modify `src/app/api/jobs/[jobId]/optimize/route.ts` + - Include workflow timings in successful responses. + - Include elapsed timing in error responses. +- Modify `src/app/api/__tests__/jobs.test.ts` + - Assert timing fields are present for analyze success, analyze LLM failure, optimize success, and optimize LLM failure. +- Create `src/lib/progress/progress.ts` + - Pure frontend progress helpers: elapsed formatting, notice text, and display stage definitions. +- Create `src/lib/progress/__tests__/progress.test.ts` + - Unit-test progress helper behavior without adding React testing dependencies. +- Create `src/components/progress-panel.tsx` + - Present current action, elapsed time, current/estimated stage list, and final timing summary. +- Modify `src/app/page.tsx` + - Track progress state and elapsed seconds during analyze/confirm/optimize requests. + - Render `ProgressPanel`. + - Display completion/error messages with elapsed timing where available. +- Modify `src/app/globals.css` + - Add compact, responsive progress panel styles using the existing panel visual language. + +--- + +### Task 1: Backend Timing For Workflow And API Routes + +**Files:** +- Modify: `src/lib/workflow/orchestrator.ts` +- Modify: `src/app/api/jobs/route.ts` +- Modify: `src/app/api/jobs/[jobId]/optimize/route.ts` +- Test: `src/app/api/__tests__/jobs.test.ts` + +- [ ] **Step 1: Write failing API timing tests** + +Add timing fields to the existing response interfaces near the top of `src/app/api/__tests__/jobs.test.ts`: + +```ts +interface TimingStepResponse { + label: string; + duration_ms: number; +} + +interface TimingResponse { + total_ms: number; + steps: TimingStepResponse[]; +} + +interface CreateJobResponse { + job: { id: string }; + candidateFactCard: { company_full_name: string }; + timing: TimingResponse; +} + +interface OptimizeJobResponse { + optimizedArticle: { title: string }; + qaReport: { checks: unknown[] }; + timing: TimingResponse; +} +``` + +Update the existing "validates input, creates a job, and returns a candidate fact card" test with these assertions after the candidate fact card assertion: + +```ts +expect(body.timing.total_ms).toBeGreaterThanOrEqual(0); +expect(body.timing.steps).toEqual([ + expect.objectContaining({ + label: "事实卡提取", + duration_ms: expect.any(Number), + }), +]); +``` + +Update the existing "returns a clear error when LLM fact extraction fails" test by changing the response body type and adding timing assertions: + +```ts +const body = (await response.json()) as { error: string; timing: TimingResponse }; + +expect(response.status).toBe(502); +expect(body.error).toBe( + "LLM response failed schema validation: target_audience", +); +expect(body.timing.total_ms).toBeGreaterThanOrEqual(0); +expect(body.timing.steps).toEqual([ + expect.objectContaining({ + label: "事实卡提取", + duration_ms: expect.any(Number), + }), +]); +``` + +Update the existing "returns optimized article and QA report for successful optimization" test with these assertions after `expect(body.qaReport.checks).toHaveLength(10);`: + +```ts +expect(body.timing.total_ms).toBeGreaterThanOrEqual(0); +expect(body.timing.steps.map((step) => step.label)).toEqual([ + "生成优化稿", + "质量检查", +]); +expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true); +``` + +Update the existing "returns a clear error when LLM optimization fails" test by changing the response body type and adding timing assertions: + +```ts +const body = (await response.json()) as { error: string; timing: TimingResponse }; + +expect(response.status).toBe(502); +expect(body.error).toBe("LLM response failed schema validation: body_markdown"); +expect(body.timing.total_ms).toBeGreaterThanOrEqual(0); +expect(body.timing.steps).toEqual([]); +``` + +- [ ] **Step 2: Run API tests to verify they fail** + +Run: + +```bash +npm test -- src/app/api/__tests__/jobs.test.ts +``` + +Expected: FAIL because `body.timing` is undefined on `/api/jobs` and `/optimize` responses. + +- [ ] **Step 3: Add workflow timing implementation** + +Replace `src/lib/workflow/orchestrator.ts` with this implementation shape, preserving the existing imports: + +```ts +import type { ArticleInput, ConfirmedFactCard } from "../domain/types"; + +import { optimizeArticle } from "./article-optimizer"; +import { inspectQualityWithLlm } from "./quality-inspector"; +import { rewriteFailedSections } from "./targeted-rewriter"; + +export interface RunOptimizationWorkflowInput { + input: ArticleInput; + factCard: ConfirmedFactCard; +} + +export interface WorkflowTimingStep { + label: string; + duration_ms: number; +} + +export interface WorkflowTimingSummary { + total_ms: number; + steps: WorkflowTimingStep[]; +} + +async function timedStep( + label: string, + steps: WorkflowTimingStep[], + action: () => Promise, +): Promise { + const startedAt = Date.now(); + try { + return await action(); + } finally { + steps.push({ + label, + duration_ms: Date.now() - startedAt, + }); + } +} + +export async function runOptimizationWorkflow({ + input, + factCard, +}: RunOptimizationWorkflowInput) { + const startedAt = Date.now(); + const timingSteps: WorkflowTimingStep[] = []; + let article = await timedStep("生成优化稿", timingSteps, () => + optimizeArticle({ input, factCard }), + ); + let qaReport = await timedStep("质量检查", timingSteps, () => + inspectQualityWithLlm({ + article, + factCard, + platform: input.platform, + sourceImages: input.images, + }), + ); + let rewriteRounds = 0; + + while (qaReport.overall_status === "fail" && rewriteRounds < 2) { + const nextRound = rewriteRounds + 1; + const failedChecks = qaReport.checks.filter((check) => check.status === "fail"); + article = await timedStep(`定向修复第 ${nextRound} 轮`, timingSteps, () => + rewriteFailedSections({ article, factCard, failedChecks }), + ); + rewriteRounds = nextRound; + qaReport = await timedStep(`质量复检第 ${nextRound} 轮`, timingSteps, () => + inspectQualityWithLlm({ + article, + factCard, + platform: input.platform, + sourceImages: input.images, + }), + ); + } + + return { + article, + qaReport, + rewrite_rounds: rewriteRounds, + stopped_after_max_rewrites: + qaReport.overall_status === "fail" && rewriteRounds >= 2, + timing: { + total_ms: Date.now() - startedAt, + steps: timingSteps, + } satisfies WorkflowTimingSummary, + }; +} +``` + +- [ ] **Step 4: Add `/api/jobs` timing** + +In `src/app/api/jobs/route.ts`, measure fact extraction around `extractCandidateFactCard`. The `POST` function should use this structure inside the existing `try` block: + +```ts +const payload = (await request.json()) as RawArticleInput; +const normalized = normalizeInput(payload); +const repository = getRepositoryFromRuntime(); +const job = await repository.createArticleJob({ + source_title: normalized.articleInput.title, + source_body: normalized.articleInput.body, + image_inputs: normalized.articleInput.images, + publish_platform: normalized.articleInput.platform, + user_instructions: normalized.articleInput.user_instructions, +}); +const factStartedAt = Date.now(); +const timing = { + total_ms: 0, + steps: [] as Array<{ label: string; duration_ms: number }>, +}; +try { + const candidateFactCard = await extractCandidateFactCard(normalized.articleInput); + const duration = Date.now() - factStartedAt; + timing.total_ms = duration; + timing.steps.push({ label: "事实卡提取", duration_ms: duration }); + + return NextResponse.json({ job, candidateFactCard, timing }, { status: 201 }); +} catch (error) { + const duration = Date.now() - factStartedAt; + timing.total_ms = duration; + timing.steps.push({ label: "事实卡提取", duration_ms: duration }); + return jsonError(error, getErrorStatus(error), timing); +} +``` + +Change `jsonError` in the same file to accept optional timing: + +```ts +function jsonError( + error: unknown, + status: number, + timing?: { total_ms: number; steps: Array<{ label: string; duration_ms: number }> }, +) { + const message = error instanceof Error ? error.message : "Request failed"; + return NextResponse.json( + timing ? { error: message, timing } : { error: message }, + { status }, + ); +} +``` + +Keep the outer catch for request parsing and validation, but leave it as: + +```ts +} catch (error) { + return jsonError(error, getErrorStatus(error)); +} +``` + +- [ ] **Step 5: Add `/optimize` timing to success and error responses** + +In `src/app/api/jobs/[jobId]/optimize/route.ts`, create a timer before the existing `try` block: + +```ts +const requestStartedAt = Date.now(); +``` + +Add `timing: result.timing` to the successful JSON response: + +```ts +return NextResponse.json({ + optimizedArticle, + qaReport, + exportPaths, + rewriteRounds: result.rewrite_rounds, + stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, + timing: result.timing, +}); +``` + +Change the catch block to include timing for failures: + +```ts +} catch (error) { + const message = error instanceof Error ? error.message : "LLM optimization failed"; + return NextResponse.json( + { + error: message, + timing: { + total_ms: Date.now() - requestStartedAt, + steps: [], + }, + }, + { status: getErrorStatus(error) }, + ); +} +``` + +- [ ] **Step 6: Run API tests to verify they pass** + +Run: + +```bash +npm test -- src/app/api/__tests__/jobs.test.ts +``` + +Expected: PASS with all job API route tests passing. + +- [ ] **Step 7: Commit backend timing** + +Run: + +```bash +git add src/lib/workflow/orchestrator.ts src/app/api/jobs/route.ts 'src/app/api/jobs/[jobId]/optimize/route.ts' src/app/api/__tests__/jobs.test.ts +git commit -m "feat: add workflow timing summaries" +``` + +--- + +### Task 2: Frontend Progress Helpers + +**Files:** +- Create: `src/lib/progress/progress.ts` +- Create: `src/lib/progress/__tests__/progress.test.ts` + +- [ ] **Step 1: Write failing progress helper tests** + +Create `src/lib/progress/__tests__/progress.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { + getElapsedNotice, + getProgressStages, + formatElapsedSeconds, +} from "../progress"; + +describe("progress helpers", () => { + it("formats elapsed seconds as short Chinese text", () => { + expect(formatElapsedSeconds(0)).toBe("0 秒"); + expect(formatElapsedSeconds(39)).toBe("39 秒"); + expect(formatElapsedSeconds(65)).toBe("1 分 5 秒"); + }); + + it("uses a neutral 10-40 second expectation without naming a model", () => { + expect(getElapsedNotice(5)).toBe("通常需要 10-40 秒,请保持页面打开。"); + expect(getElapsedNotice(45)).toBe("仍在处理中,请保持页面打开。"); + expect(getElapsedNotice(75)).toBe("耗时较长,仍在等待服务返回。"); + expect(getElapsedNotice(5)).not.toMatch(/DeepSeek|OpenAI|模型|model/i); + }); + + it("returns stage labels for analyze and optimize actions", () => { + expect(getProgressStages("analyze").map((stage) => stage.label)).toEqual([ + "读取文章输入", + "提取事实卡", + "生成待确认信息", + ]); + expect(getProgressStages("optimize").map((stage) => stage.label)).toEqual([ + "生成优化稿", + "质量检查", + "必要时定向修复", + "整理结果", + ]); + }); +}); +``` + +- [ ] **Step 2: Run helper tests to verify they fail** + +Run: + +```bash +npm test -- src/lib/progress/__tests__/progress.test.ts +``` + +Expected: FAIL because `src/lib/progress/progress.ts` does not exist. + +- [ ] **Step 3: Implement progress helpers** + +Create `src/lib/progress/progress.ts`: + +```ts +export type ProgressAction = "analyze" | "confirm" | "optimize"; + +export interface ProgressStage { + label: string; +} + +const progressStages: Record = { + analyze: [ + { label: "读取文章输入" }, + { label: "提取事实卡" }, + { label: "生成待确认信息" }, + ], + confirm: [ + { label: "校验事实卡" }, + { label: "保存品牌事实" }, + ], + optimize: [ + { label: "生成优化稿" }, + { label: "质量检查" }, + { label: "必要时定向修复" }, + { label: "整理结果" }, + ], +}; + +export function formatElapsedSeconds(seconds: number) { + const safeSeconds = Math.max(0, Math.floor(seconds)); + if (safeSeconds < 60) return `${safeSeconds} 秒`; + const minutes = Math.floor(safeSeconds / 60); + const remainingSeconds = safeSeconds % 60; + return `${minutes} 分 ${remainingSeconds} 秒`; +} + +export function getElapsedNotice(seconds: number) { + if (seconds >= 60) return "耗时较长,仍在等待服务返回。"; + if (seconds >= 40) return "仍在处理中,请保持页面打开。"; + return "通常需要 10-40 秒,请保持页面打开。"; +} + +export function getProgressStages(action: ProgressAction) { + return progressStages[action]; +} +``` + +- [ ] **Step 4: Run helper tests to verify they pass** + +Run: + +```bash +npm test -- src/lib/progress/__tests__/progress.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit progress helpers** + +Run: + +```bash +git add src/lib/progress/progress.ts src/lib/progress/__tests__/progress.test.ts +git commit -m "feat: add progress display helpers" +``` + +--- + +### Task 3: Frontend Progress Panel And Messages + +**Files:** +- Create: `src/components/progress-panel.tsx` +- Modify: `src/app/page.tsx` +- Modify: `src/app/globals.css` + +- [ ] **Step 1: Add progress response and state types in `src/app/page.tsx`** + +Update imports in `src/app/page.tsx`: + +```ts +import { useEffect, useMemo, useState } from "react"; +``` + +Add imports for the new component and type: + +```ts +import { ProgressPanel } from "../components/progress-panel"; +import type { ProgressAction } from "../lib/progress/progress"; +``` + +Add timing interfaces near the existing response interfaces: + +```ts +interface TimingStep { + label: string; + duration_ms: number; +} + +interface TimingSummary { + total_ms: number; + steps: TimingStep[]; +} +``` + +Extend response interfaces: + +```ts +interface CreateJobResponse extends ApiErrorResponse { + job: { id: string }; + candidateFactCard: CandidateFactCard; + timing?: TimingSummary; +} + +interface OptimizeJobResponse extends ApiErrorResponse { + optimizedArticle: OptimizedArticle; + qaReport: QaReport; + timing?: TimingSummary; +} +``` + +Add component state inside `Home`: + +```ts +const [elapsedSeconds, setElapsedSeconds] = useState(0); +const [lastTiming, setLastTiming] = useState(null); +``` + +Add this effect below `canOptimize`: + +```ts +useEffect(() => { + if (!busyAction) return; + setElapsedSeconds(0); + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + return () => window.clearInterval(timer); +}, [busyAction]); +``` + +- [ ] **Step 2: Update request handlers to manage progress timing** + +At the start of `analyze`, after `setMessage("");`, add: + +```ts +setLastTiming(null); +``` + +After setting the candidate fact card, replace `setMessage("候选事实卡已生成。");` with: + +```ts +setLastTiming(body.timing ?? null); +setMessage( + body.timing + ? `候选事实卡已生成,用时 ${formatTiming(body.timing.total_ms)}。` + : "候选事实卡已生成。", +); +``` + +In the `catch` block of `analyze`, parse timing from API errors by replacing the current catch body with: + +```ts +setMessage(error instanceof Error ? error.message : "分析失败"); +``` + +Keep the catch simple for this iteration because the thrown `Error` only carries a message; timing on error will be visible after Task 4 if the UI adopts a typed fetch helper. + +At the start of `confirmFactCard`, after `setMessage("");`, add: + +```ts +setLastTiming(null); +``` + +At the start of `optimize`, after `setMessage("");`, add: + +```ts +setLastTiming(null); +``` + +After setting the QA report, add: + +```ts +setLastTiming(body.timing ?? null); +``` + +Replace the existing optimize success `setMessage(...)` with: + +```ts +const timingText = body.timing + ? `用时 ${formatTiming(body.timing.total_ms)}。` + : ""; +setMessage( + body.qaReport.overall_status === "fail" + ? `质检发现硬性失败,已阻止导出。${timingText}` + : `优化完成。${timingText}`, +); +``` + +Add this helper below `apiHeaders`: + +```ts +function formatTiming(milliseconds: number) { + const seconds = Math.round(milliseconds / 1000); + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes} 分 ${remainingSeconds} 秒`; +} +``` + +- [ ] **Step 3: Create `ProgressPanel` component** + +Create `src/components/progress-panel.tsx`: + +```tsx +"use client"; + +import { + formatElapsedSeconds, + getElapsedNotice, + getProgressStages, + type ProgressAction, +} from "../lib/progress/progress"; + +interface TimingStep { + label: string; + duration_ms: number; +} + +interface TimingSummary { + total_ms: number; + steps: TimingStep[]; +} + +interface ProgressPanelProps { + action: ProgressAction | null; + elapsedSeconds: number; + lastTiming: TimingSummary | null; +} + +export function ProgressPanel({ + action, + elapsedSeconds, + lastTiming, +}: ProgressPanelProps) { + if (!action && !lastTiming) return null; + + return ( +
+ {action ? ( + <> +
+ {getActionLabel(action)} + 已用时 {formatElapsedSeconds(elapsedSeconds)} +
+

{getElapsedNotice(elapsedSeconds)}

+
    + {getProgressStages(action).map((stage) => ( +
  1. {stage.label}
  2. + ))} +
+ + ) : ( + <> +
+ 最近一次耗时 + {formatMilliseconds(lastTiming.total_ms)} +
+ {lastTiming.steps.length > 0 && ( +
    + {lastTiming.steps.map((step) => ( +
  1. + {step.label}: {formatMilliseconds(step.duration_ms)} +
  2. + ))} +
+ )} + + )} +
+ ); +} + +function getActionLabel(action: ProgressAction) { + if (action === "analyze") return "正在分析文章"; + if (action === "confirm") return "正在确认事实卡"; + return "正在优化文章"; +} + +function formatMilliseconds(milliseconds: number) { + const seconds = Math.round(milliseconds / 1000); + if (seconds < 60) return `${seconds} 秒`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes} 分 ${remainingSeconds} 秒`; +} +``` + +- [ ] **Step 4: Render progress panel in `src/app/page.tsx`** + +Add this immediately after the `` closing tag and before `
`: + +```tsx + +``` + +- [ ] **Step 5: Add progress styles** + +Append to `src/app/globals.css` before the `@media` block: + +```css +.progress-panel { + border: 1px solid #dce2eb; + border-radius: 8px; + background: #ffffff; + display: grid; + gap: 0.65rem; + padding: 0.85rem 1rem; +} + +.progress-summary { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; +} + +.progress-summary strong { + color: #172033; +} + +.progress-summary span, +.progress-panel p { + color: #586174; +} + +.progress-panel p { + margin: 0; +} + +.progress-steps { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.progress-steps li { + border: 1px solid #cbd3df; + border-radius: 999px; + color: #586174; + padding: 0.25rem 0.55rem; +} +``` + +- [ ] **Step 6: Run targeted tests and build** + +Run: + +```bash +npm test -- src/lib/progress/__tests__/progress.test.ts src/app/api/__tests__/jobs.test.ts +npm run build +``` + +Expected: tests PASS and build PASS. + +- [ ] **Step 7: Commit frontend progress UI** + +Run: + +```bash +git add src/app/page.tsx src/components/progress-panel.tsx src/app/globals.css +git commit -m "feat: show request progress timing" +``` + +--- + +### Task 4: End-To-End Verification + +**Files:** +- No new source files. +- Verify: local server at `http://localhost:3001/` + +- [ ] **Step 1: Run full automated verification** + +Run: + +```bash +npm test +npm run build +``` + +Expected: + +```text +Test Files 14 passed +``` + +The exact test count may be higher than the current count because this plan adds `src/lib/progress/__tests__/progress.test.ts`. `npm run build` must finish with the Next route summary and exit code 0. + +- [ ] **Step 2: Start or reuse local dev server** + +If port 3001 is already running, reuse it. Otherwise run: + +```bash +npm run dev -- --port 3001 +``` + +Expected: server listens on `http://localhost:3001/`. + +- [ ] **Step 3: Manual UI smoke test** + +Open `http://localhost:3001/` and verify: + +1. Enter access key `local-dev-key`. +2. Submit an article for analysis. +3. While waiting, the progress panel shows: + +```text +正在分析文章 +已用时 X 秒 +通常需要 10-40 秒,请保持页面打开。 +``` + +4. Confirm the fact card. +5. Click "开始优化". +6. While waiting, the progress panel shows: + +```text +正在优化文章 +已用时 X 秒 +通常需要 10-40 秒,请保持页面打开。 +``` + +7. Confirm the text does not mention any specific model/vendor name in the progress copy. +8. After completion, confirm the message includes total elapsed time and the progress panel shows backend stage timings. + +- [ ] **Step 4: Check git status** + +Run: + +```bash +git status --short +``` + +Expected: no uncommitted source changes except ignored local runtime data such as generated `data/exports` files. + +--- + +## Self-Review + +**Spec coverage:** +The plan covers running progress display, elapsed time, the exact neutral "通常需要 10-40 秒" wording, backend timing summaries, API response timing, success and failure timing behavior, tests, build verification, and browser smoke testing. + +**Placeholder scan:** +No placeholder wording or vague implementation instructions remain. Each code-changing step includes concrete code or exact assertions. + +**Type consistency:** +The timing shape is consistently named `TimingSummary` on the frontend/API test side and `WorkflowTimingSummary` in workflow code. Both use `total_ms` and `steps: { label, duration_ms }[]`, matching all planned route responses and UI reads. + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-06-17-progress-timing.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach?