24 KiB
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/jobsfact-card extraction elapsed time and return atimingobject for success and LLM failure responses.
- Measure
- 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:
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:
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:
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);:
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:
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:
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:
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<T>(
label: string,
steps: WorkflowTimingStep[],
action: () => Promise<T>,
): Promise<T> {
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/jobstiming
In src/app/api/jobs/route.ts, measure fact extraction around extractCandidateFactCard. The POST function should use this structure inside the existing try block:
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:
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:
} catch (error) {
return jsonError(error, getErrorStatus(error));
}
- Step 5: Add
/optimizetiming to success and error responses
In src/app/api/jobs/[jobId]/optimize/route.ts, create a timer before the existing try block:
const requestStartedAt = Date.now();
Add timing: result.timing to the successful JSON response:
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:
} 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:
npm test -- src/app/api/__tests__/jobs.test.ts
Expected: PASS with all job API route tests passing.
- Step 7: Commit backend timing
Run:
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:
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:
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:
export type ProgressAction = "analyze" | "confirm" | "optimize";
export interface ProgressStage {
label: string;
}
const progressStages: Record<ProgressAction, ProgressStage[]> = {
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:
npm test -- src/lib/progress/__tests__/progress.test.ts
Expected: PASS.
- Step 5: Commit progress helpers
Run:
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:
import { useEffect, useMemo, useState } from "react";
Add imports for the new component and type:
import { ProgressPanel } from "../components/progress-panel";
import type { ProgressAction } from "../lib/progress/progress";
Add timing interfaces near the existing response interfaces:
interface TimingStep {
label: string;
duration_ms: number;
}
interface TimingSummary {
total_ms: number;
steps: TimingStep[];
}
Extend response interfaces:
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:
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
Add this effect below canOptimize:
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:
setLastTiming(null);
After setting the candidate fact card, replace setMessage("候选事实卡已生成。"); with:
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:
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:
setLastTiming(null);
At the start of optimize, after setMessage("");, add:
setLastTiming(null);
After setting the QA report, add:
setLastTiming(body.timing ?? null);
Replace the existing optimize success setMessage(...) with:
const timingText = body.timing
? `用时 ${formatTiming(body.timing.total_ms)}。`
: "";
setMessage(
body.qaReport.overall_status === "fail"
? `质检发现硬性失败,已阻止导出。${timingText}`
: `优化完成。${timingText}`,
);
Add this helper below apiHeaders:
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
ProgressPanelcomponent
Create src/components/progress-panel.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 (
<section className="progress-panel" aria-live="polite">
{action ? (
<>
<div className="progress-summary">
<strong>{getActionLabel(action)}</strong>
<span>已用时 {formatElapsedSeconds(elapsedSeconds)}</span>
</div>
<p>{getElapsedNotice(elapsedSeconds)}</p>
<ol className="progress-steps">
{getProgressStages(action).map((stage) => (
<li key={stage.label}>{stage.label}</li>
))}
</ol>
</>
) : (
<>
<div className="progress-summary">
<strong>最近一次耗时</strong>
<span>{formatMilliseconds(lastTiming.total_ms)}</span>
</div>
{lastTiming.steps.length > 0 && (
<ol className="progress-steps">
{lastTiming.steps.map((step) => (
<li key={`${step.label}-${step.duration_ms}`}>
{step.label}: {formatMilliseconds(step.duration_ms)}
</li>
))}
</ol>
)}
</>
)}
</section>
);
}
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 </header> closing tag and before <div className="workflow-grid">:
<ProgressPanel
action={busyAction as ProgressAction | null}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
/>
- Step 5: Add progress styles
Append to src/app/globals.css before the @media block:
.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:
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:
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:
npm test
npm run build
Expected:
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:
npm run dev -- --port 3001
Expected: server listens on http://localhost:3001/.
- Step 3: Manual UI smoke test
Open http://localhost:3001/ and verify:
- Enter access key
local-dev-key. - Submit an article for analysis.
- While waiting, the progress panel shows:
正在分析文章
已用时 X 秒
通常需要 10-40 秒,请保持页面打开。
- Confirm the fact card.
- Click "开始优化".
- While waiting, the progress panel shows:
正在优化文章
已用时 X 秒
通常需要 10-40 秒,请保持页面打开。
- Confirm the text does not mention any specific model/vendor name in the progress copy.
- After completion, confirm the message includes total elapsed time and the progress panel shows backend stage timings.
- Step 4: Check git status
Run:
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?