Files
2026-06-21 23:43:03 +08:00

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/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:

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/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:

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 /optimize timing 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 ProgressPanel component

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:

  1. Enter access key local-dev-key.
  2. Submit an article for analysis.
  3. While waiting, the progress panel shows:
正在分析文章
已用时 X 秒
通常需要 10-40 秒,请保持页面打开。
  1. Confirm the fact card.
  2. Click "开始优化".
  3. While waiting, the progress panel shows:
正在优化文章
已用时 X 秒
通常需要 10-40 秒,请保持页面打开。
  1. Confirm the text does not mention any specific model/vendor name in the progress copy.
  2. 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?