# 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?