docs: plan progress timing ui
This commit is contained in:
@@ -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<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:
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<TimingSummary | null>(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 (
|
||||
<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">`:
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```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?
|
||||
Reference in New Issue
Block a user