Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-07-01-one-click-streaming-optimization.md
T

2143 lines
61 KiB
Markdown

# 一键流式优化主流程 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:** Replace the GEO main workflow with one-click optimization that accepts a pasted article, streams phase events, shows a compact fact card, and animates the result area from draft to final article.
**Architecture:** Add an NDJSON event contract and streaming workflow wrapper that reuses the existing LLM nodes. Add `POST /api/jobs/optimize-stream` as the new main path while keeping old job/create/confirm/optimize routes compatible. Update the homepage to read the stream via `fetch`, show a compact editable fact card, and play real backend article events with cursor, breathing highlight, and typewriter effects.
**Tech Stack:** Next.js App Router route handlers, React client components, TypeScript, Zod, existing `generateValidatedJson` LLM client, SQLite/D1 repository boundary, Vitest, Playwright.
---
## File Structure
- Modify `src/lib/domain/types.ts`
- Allow article titles to be empty.
- Add `OptimizationFactCard`.
- Make workflow nodes accept optimization fact cards without pretending they were user-confirmed.
- Modify `src/lib/domain/validation.ts`
- Allow `articleInputSchema.title` to be empty.
- Add `optimizationFactCardSchema`.
- Modify `src/lib/domain/__tests__/validation.test.ts`
- Cover empty title, empty body rejection, and unconfirmed optimization fact cards.
- Modify `src/lib/workflow/input-normalizer.ts`
- Normalize missing titles to `""`.
- Preserve non-empty body as the only required text field.
- Modify `src/lib/workflow/article-optimizer.ts`
- Modify `src/lib/workflow/quality-inspector.ts`
- Modify `src/lib/workflow/targeted-rewriter.ts`
- Modify `src/lib/workflow/orchestrator.ts`
- Modify `src/lib/llm/prompts.ts`
- Accept `OptimizationFactCard` where downstream optimization only needs fact constraints.
- Modify `src/lib/db/repository.ts`
- Modify `src/lib/db/repositories.ts`
- Modify `src/lib/db/sqlite-repository.ts`
- Modify `src/lib/db/d1-repository.ts`
- Loosen fact-card repository methods to `OptimizationFactCard`.
- Create `src/lib/workflow/stream-events.ts`
- Shared event union, NDJSON encoder, and browser-safe stream parser.
- Create `src/lib/workflow/__tests__/stream-events.test.ts`
- Unit tests for event encoding and chunk parsing.
- Create `src/lib/workflow/streaming-optimizer.ts`
- Streaming workflow wrapper that emits draft/QA/rewrite events and returns final article state.
- Create `src/lib/workflow/__tests__/streaming-optimizer.test.ts`
- Mocked workflow tests for event order and rewrite behavior.
- Create `src/app/api/jobs/optimize-stream/route.ts`
- Protected streaming route.
- Modify `src/app/api/__tests__/jobs.test.ts`
- Add streaming-route tests.
- Modify `src/components/article-input-form.tsx`
- Convert to one-click input with optional title in details.
- Modify `src/components/fact-card-editor.tsx`
- Add compact mode and remove confirm requirement from the main path.
- Modify `src/components/optimized-preview.tsx`
- Add streaming result states, cursor, typewriter rendering, and final display.
- Modify `src/app/page.tsx`
- Replace analyze/confirm/optimize button choreography with `startStreamingOptimization`.
- Modify `src/app/globals.css`
- Add compact fact-card and streaming result styles.
- Modify `tests/e2e/mvp.spec.ts`
- Update the main E2E path to paste only body and consume mocked stream events.
---
## Task 1: Domain Contracts For Optional Title And Optimization Fact Cards
**Files:**
- Modify: `src/lib/domain/types.ts`
- Modify: `src/lib/domain/validation.ts`
- Modify: `src/lib/domain/__tests__/validation.test.ts`
- Modify: `src/lib/workflow/input-normalizer.ts`
- Modify: `src/lib/workflow/__tests__/workflow.test.ts`
- Modify: `src/lib/workflow/article-optimizer.ts`
- Modify: `src/lib/workflow/quality-inspector.ts`
- Modify: `src/lib/workflow/targeted-rewriter.ts`
- Modify: `src/lib/workflow/orchestrator.ts`
- Modify: `src/lib/llm/prompts.ts`
- Modify: `src/lib/db/repository.ts`
- Modify: `src/lib/db/repositories.ts`
- Modify: `src/lib/db/sqlite-repository.ts`
- Modify: `src/lib/db/d1-repository.ts`
- [ ] **Step 1: Add failing validation tests**
In `src/lib/domain/__tests__/validation.test.ts`, update the import to include `optimizationFactCardSchema`:
```ts
import {
articleInputSchema,
confirmedFactCardSchema,
candidateFactCardSchema,
optimizationFactCardSchema,
optimizedArticleSchema,
qaReportSchema,
} from "../validation";
```
Append these tests inside `describe("domain validation", () => { ... })`:
```ts
it("accepts article input with an empty optional title", () => {
const parsed = articleInputSchema.parse({
title: " ",
body: "完整文章正文可以直接粘贴在这里。",
images: [],
platform: "official_site",
user_instructions: "",
});
expect(parsed.title).toBe("");
expect(parsed.body).toBe("完整文章正文可以直接粘贴在这里。");
});
it("still rejects article input with an empty body", () => {
expect(() =>
articleInputSchema.parse({
title: "",
body: " ",
images: [],
platform: "official_site",
user_instructions: "",
}),
).toThrow();
});
it("accepts an unconfirmed optimization fact card with unresolved items", () => {
const parsed = optimizationFactCardSchema.parse({
company_full_name: "",
company_short_names: ["示例科技"],
brand_names: [],
product_names: ["GEO内容优化平台"],
target_industry: "",
target_audience: "市场团队",
experience_years: "",
core_claims: ["提供GEO内容优化服务"],
forbidden_claims: [],
image_topics: [],
uncertain_items: ["客户案例需要确认"],
confirmed_by_user: false,
});
expect(parsed.company_full_name).toBe("");
expect(parsed.experience_years).toBeNull();
expect(parsed.confirmed_by_user).toBe(false);
expect(parsed.is_ready_for_optimization).toBe(false);
expect(parsed.uncertain_items).toEqual(["客户案例需要确认"]);
});
```
- [ ] **Step 2: Run validation tests and verify RED**
Run:
```bash
npm test -- src/lib/domain/__tests__/validation.test.ts
```
Expected: FAIL because `optimizationFactCardSchema` is not exported and `articleInputSchema.title` still rejects empty strings.
- [ ] **Step 3: Add `OptimizationFactCard` type**
In `src/lib/domain/types.ts`, replace the current fact-card interfaces:
```ts
export interface CandidateFactCard {
company_full_name: string;
company_short_names: string[];
brand_names: string[];
product_names: string[];
target_industry: string;
target_audience: string;
experience_years: number | null;
core_claims: string[];
forbidden_claims: string[];
image_topics: string[];
uncertain_items: string[];
is_ready_for_optimization: boolean;
}
export interface ConfirmedFactCard extends CandidateFactCard {
confirmed_by_user: true;
is_ready_for_optimization: true;
}
```
with:
```ts
export interface CandidateFactCard {
company_full_name: string;
company_short_names: string[];
brand_names: string[];
product_names: string[];
target_industry: string;
target_audience: string;
experience_years: number | null;
core_claims: string[];
forbidden_claims: string[];
image_topics: string[];
uncertain_items: string[];
is_ready_for_optimization: boolean;
}
export interface OptimizationFactCard extends CandidateFactCard {
confirmed_by_user: boolean;
}
export interface ConfirmedFactCard extends OptimizationFactCard {
confirmed_by_user: true;
is_ready_for_optimization: true;
}
```
- [ ] **Step 4: Add schema support**
In `src/lib/domain/validation.ts`, add `OptimizationFactCard` to the type import:
```ts
ImageInput,
OptimizationFactCard,
OptimizedArticle,
```
Replace `articleInputSchema` with:
```ts
export const articleInputSchema = z.object({
title: z.string().trim().default(""),
body: z.string().trim().min(1),
images: z.array(imageInputSchema).default([]),
platform: publishPlatformSchema,
user_instructions: z.string().trim().default(""),
}) satisfies z.ZodType<ArticleInput>;
```
After `candidateFactCardSchema`, add:
```ts
export const optimizationFactCardSchema = factCardBaseSchema
.extend({
confirmed_by_user: z.boolean().optional().default(false),
is_ready_for_optimization: z.boolean().optional(),
})
.transform((card) => ({
...card,
confirmed_by_user: card.confirmed_by_user,
is_ready_for_optimization: card.uncertain_items.length === 0,
})) satisfies z.ZodType<OptimizationFactCard>;
```
Keep `confirmedFactCardSchema` strict and unchanged except for type compatibility.
- [ ] **Step 5: Normalize missing titles**
In `src/lib/workflow/input-normalizer.ts`, change the raw input interface to allow missing titles:
```ts
export interface RawArticleInput {
title?: string;
body: string;
image_lines?: string;
images?: ImageInput[];
platform: PublishPlatform;
user_instructions?: string;
}
```
Change the schema input construction to:
```ts
const articleInput = articleInputSchema.parse({
title: input.title ?? "",
body: input.body,
images,
platform: input.platform,
user_instructions: input.user_instructions ?? "",
});
```
- [ ] **Step 6: Update downstream fact-card types**
Change these imports and function input types from `ConfirmedFactCard` to `OptimizationFactCard`:
In `src/lib/workflow/article-optimizer.ts`:
```ts
import type {
ArticleInput,
OptimizationFactCard,
OptimizedArticle,
} from "../domain/types";
export interface OptimizeArticleInput {
input: ArticleInput;
factCard: OptimizationFactCard;
}
```
In `src/lib/workflow/quality-inspector.ts`, update any `ConfirmedFactCard` import and field to `OptimizationFactCard`:
```ts
import type {
ImageInput,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaCheck,
QaReport,
} from "../domain/types";
```
The quality-inspector input should use:
```ts
factCard: OptimizationFactCard;
```
In `src/lib/workflow/targeted-rewriter.ts`, use:
```ts
import type { OptimizationFactCard, OptimizedArticle, QaCheck } from "../domain/types";
```
and:
```ts
factCard: OptimizationFactCard;
```
In `src/lib/workflow/orchestrator.ts`, use:
```ts
import type { ArticleInput, OptimizationFactCard } from "../domain/types";
```
and:
```ts
export interface RunOptimizationWorkflowInput {
input: ArticleInput;
factCard: OptimizationFactCard;
onProgress?: (event: WorkflowProgressEvent) => void | Promise<void>;
}
```
In `src/lib/llm/prompts.ts`, replace `ConfirmedFactCard` with `OptimizationFactCard` for `buildArticleOptimizerPrompt`, `buildQualityInspectorPrompt`, and `buildTargetedRewritePrompt`:
```ts
import type {
ArticleInput,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaCheck,
} from "../domain/types";
```
The prompt parameter should be:
```ts
factCard: OptimizationFactCard
```
- [ ] **Step 7: Update repository fact-card method types**
In `src/lib/db/repository.ts`, import `OptimizationFactCard`:
```ts
import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types";
```
Change the repository methods:
```ts
saveFactCard(
jobId: string,
factCard: OptimizationFactCard,
): Promise<{ job_id: string } & OptimizationFactCard>;
getFactCard(
jobId: string,
): Promise<({ job_id: string } & OptimizationFactCard) | null>;
```
In `src/lib/db/repositories.ts`, import `OptimizationFactCard` and change `saveFactCard`:
```ts
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
import type {
ImageInput,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaReport,
} from "../domain/types";
```
Change the function signature:
```ts
export function saveFactCard(
dbPath: string | undefined,
jobId: string,
factCard: OptimizationFactCard,
) {
```
Change the saved source:
```ts
source: factCard.confirmed_by_user
? "auto_extract_then_user_confirmed"
: "auto_extract_for_optimization",
```
Change `getFactCard` parsing:
```ts
? { job_id: row.job_id, ...parseJson<OptimizationFactCard>(row.fact_card) }
```
In `src/lib/db/d1-repository.ts`, change the domain type import to include `OptimizationFactCard`:
```ts
import type {
ImageInput,
OptimizationFactCard,
OptimizedArticle,
PublishPlatform,
QaReport,
} from "../domain/types";
```
Change `saveFactCard` so the persisted `source` reflects whether the user explicitly confirmed the card:
```ts
factCard.confirmed_by_user
? "auto_extract_then_user_confirmed"
: "auto_extract_for_optimization",
```
Change `getFactCard` parsing:
```ts
? { job_id: row.job_id, ...parseJson<OptimizationFactCard>(row.fact_card) }
```
`src/lib/db/sqlite-repository.ts` delegates to `saveFactCard` from `repositories.ts` through the `AppRepository` interface, so it should compile without a direct annotation change after the repository interface is updated.
- [ ] **Step 8: Update workflow normalization test**
In `src/lib/workflow/__tests__/workflow.test.ts`, add this test inside `describe("workflow nodes", () => { ... })`:
```ts
it("normalizes missing article titles to an empty string", () => {
const normalized = normalizeInput({
body: "只有正文也可以开始优化。",
image_lines: "",
platform: "official_site",
user_instructions: "",
});
expect(normalized.article_draft.title).toBe("");
expect(normalized.articleInput.title).toBe("");
expect(normalized.articleInput.body).toBe("只有正文也可以开始优化。");
});
```
- [ ] **Step 9: Run focused tests and typecheck through Vitest**
Run:
```bash
npm test -- src/lib/domain/__tests__/validation.test.ts src/lib/workflow/__tests__/workflow.test.ts src/lib/workflow/__tests__/orchestrator.test.ts src/lib/workflow/__tests__/llm-integration.test.ts
```
Expected: PASS.
- [ ] **Step 10: Commit Task 1**
```bash
git add src/lib/domain/types.ts src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts src/lib/workflow/input-normalizer.ts src/lib/workflow/__tests__/workflow.test.ts src/lib/workflow/article-optimizer.ts src/lib/workflow/quality-inspector.ts src/lib/workflow/targeted-rewriter.ts src/lib/workflow/orchestrator.ts src/lib/llm/prompts.ts src/lib/db/repository.ts src/lib/db/repositories.ts src/lib/db/sqlite-repository.ts src/lib/db/d1-repository.ts
git commit -m "放宽标题和事实卡优化约束"
```
---
## Task 2: Shared Stream Event Contract
**Files:**
- Create: `src/lib/workflow/stream-events.ts`
- Create: `src/lib/workflow/__tests__/stream-events.test.ts`
- [ ] **Step 1: Write failing stream event tests**
Create `src/lib/workflow/__tests__/stream-events.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import {
encodeOptimizationStreamEvent,
parseOptimizationStreamChunk,
type OptimizationStreamEvent,
} from "../stream-events";
describe("optimization stream events", () => {
it("encodes each event as one JSON line", () => {
const event: OptimizationStreamEvent = {
type: "draft_started",
job_id: "job_123",
message: "正在生成优化草稿",
};
expect(encodeOptimizationStreamEvent(event)).toBe(
'{"type":"draft_started","job_id":"job_123","message":"正在生成优化草稿"}\n',
);
});
it("parses chunked NDJSON while preserving incomplete lines", () => {
const first = parseOptimizationStreamChunk("", '{"type":"job_created","job":{"id":"job_');
expect(first.events).toEqual([]);
expect(first.remainder).toBe('{"type":"job_created","job":{"id":"job_');
const second = parseOptimizationStreamChunk(
first.remainder,
'123"}}\n{"type":"draft_started","job_id":"job_123","message":"正在生成"}\n{"type":"qa_started"',
);
expect(second.events).toEqual([
{ type: "job_created", job: { id: "job_123" } },
{ type: "draft_started", job_id: "job_123", message: "正在生成" },
]);
expect(second.remainder).toBe('{"type":"qa_started"');
});
});
```
- [ ] **Step 2: Run stream event tests and verify RED**
Run:
```bash
npm test -- src/lib/workflow/__tests__/stream-events.test.ts
```
Expected: FAIL because `stream-events.ts` does not exist.
- [ ] **Step 3: Add stream event contract**
Create `src/lib/workflow/stream-events.ts`:
```ts
import type {
OptimizationFactCard,
OptimizedArticle,
QaReport,
} from "../domain/types";
export type OptimizationStreamStage =
| "input"
| "job"
| "fact_card"
| "draft"
| "qa"
| "rewrite"
| "final";
export type OptimizationStreamEvent =
| { type: "job_created"; job: { id: string } }
| {
type: "fact_card_ready";
job_id: string;
fact_card: OptimizationFactCard;
}
| { type: "draft_started"; job_id: string; message: string }
| { type: "draft_ready"; job_id: string; article: OptimizedArticle }
| { type: "qa_started"; job_id: string; message: string }
| { type: "qa_ready"; job_id: string; qa_report: QaReport }
| { type: "rewrite_started"; job_id: string; round: number }
| {
type: "rewrite_ready";
job_id: string;
round: number;
article: OptimizedArticle;
}
| {
type: "final_ready";
job_id: string;
optimized_article: OptimizedArticle;
qa_report: QaReport;
export_paths: Record<string, string>;
}
| {
type: "failed";
job_id?: string;
stage: OptimizationStreamStage;
error: string;
};
export function encodeOptimizationStreamEvent(
event: OptimizationStreamEvent,
) {
return `${JSON.stringify(event)}\n`;
}
export function parseOptimizationStreamChunk(
previousRemainder: string,
chunk: string,
) {
const text = previousRemainder + chunk;
const lines = text.split(/\n/);
const remainder = lines.pop() ?? "";
const events = lines
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as OptimizationStreamEvent);
return { events, remainder };
}
```
- [ ] **Step 4: Run stream event tests and verify GREEN**
Run:
```bash
npm test -- src/lib/workflow/__tests__/stream-events.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 2**
```bash
git add src/lib/workflow/stream-events.ts src/lib/workflow/__tests__/stream-events.test.ts
git commit -m "新增优化流事件契约"
```
---
## Task 3: Streaming Workflow Wrapper
**Files:**
- Create: `src/lib/workflow/streaming-optimizer.ts`
- Create: `src/lib/workflow/__tests__/streaming-optimizer.test.ts`
- [ ] **Step 1: Write failing streaming workflow tests**
Create `src/lib/workflow/__tests__/streaming-optimizer.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import type { OptimizationStreamEvent } from "../stream-events";
import { runStreamingOptimizationWorkflow } from "../streaming-optimizer";
const workflowMocks = vi.hoisted(() => ({
optimizeArticle: vi.fn(),
inspectQualityWithLlm: vi.fn(),
rewriteFailedSections: vi.fn(),
}));
vi.mock("../article-optimizer", () => ({
optimizeArticle: workflowMocks.optimizeArticle,
}));
vi.mock("../quality-inspector", () => ({
inspectQualityWithLlm: workflowMocks.inspectQualityWithLlm,
}));
vi.mock("../targeted-rewriter", () => ({
rewriteFailedSections: workflowMocks.rewriteFailedSections,
}));
const input = {
title: "",
body: "示例科技提供GEO内容优化服务。",
images: [],
platform: "official_site" as const,
user_instructions: "",
};
const factCard = {
company_full_name: "",
company_short_names: ["示例科技"],
brand_names: [],
product_names: ["GEO内容优化平台"],
target_industry: "GEO内容优化",
target_audience: "市场团队",
experience_years: null,
core_claims: ["提供GEO内容优化服务"],
forbidden_claims: [],
image_topics: [],
uncertain_items: ["公司全称需要确认"],
is_ready_for_optimization: false,
confirmed_by_user: false,
};
const draftArticle = {
title: "示例科技 GEO 内容优化方案",
summary: "面向市场团队的GEO内容优化说明。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: ["标题", "正文"],
requires_user_confirmation: [],
};
const failCheck = {
rule_id: "body_quality" as const,
status: "fail" as const,
evidence: "句子不够顺。",
reason: "正文需要润色。",
suggested_fix: "润色正文。",
target_agent: "body",
};
describe("runStreamingOptimizationWorkflow", () => {
it("emits draft and final QA events when no rewrite is needed", async () => {
workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle);
workflowMocks.inspectQualityWithLlm.mockResolvedValueOnce({
overall_status: "pass",
checks: [],
});
const events: OptimizationStreamEvent[] = [];
const result = await runStreamingOptimizationWorkflow({
jobId: "job_stream",
input,
factCard,
onEvent: (event) => events.push(event),
});
expect(events.map((event) => event.type)).toEqual([
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
]);
expect(result.article.title).toBe("示例科技 GEO 内容优化方案");
expect(result.qaReport.overall_status).toBe("pass");
expect(result.rewriteRounds).toBe(0);
});
it("emits rewrite events when QA fails", async () => {
workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle);
workflowMocks.inspectQualityWithLlm
.mockResolvedValueOnce({ overall_status: "fail", checks: [failCheck] })
.mockResolvedValueOnce({ overall_status: "pass", checks: [] });
workflowMocks.rewriteFailedSections.mockResolvedValueOnce({
...draftArticle,
body_markdown: "## 服务能力\n示例科技提供清晰的GEO内容优化服务。",
});
const events: OptimizationStreamEvent[] = [];
const result = await runStreamingOptimizationWorkflow({
jobId: "job_stream",
input,
factCard,
onEvent: (event) => events.push(event),
});
expect(events.map((event) => event.type)).toEqual([
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
"rewrite_started",
"rewrite_ready",
"qa_started",
"qa_ready",
]);
expect(result.article.body_markdown).toContain("清晰的GEO内容优化服务");
expect(result.rewriteRounds).toBe(1);
});
});
```
- [ ] **Step 2: Run streaming workflow tests and verify RED**
Run:
```bash
npm test -- src/lib/workflow/__tests__/streaming-optimizer.test.ts
```
Expected: FAIL because `streaming-optimizer.ts` does not exist.
- [ ] **Step 3: Implement streaming workflow wrapper**
Create `src/lib/workflow/streaming-optimizer.ts`:
```ts
import type { ArticleInput, OptimizationFactCard } from "../domain/types";
import { optimizeArticle } from "./article-optimizer";
import { inspectQualityWithLlm } from "./quality-inspector";
import type { OptimizationStreamEvent } from "./stream-events";
import { rewriteFailedSections } from "./targeted-rewriter";
export interface RunStreamingOptimizationWorkflowInput {
jobId: string;
input: ArticleInput;
factCard: OptimizationFactCard;
onEvent: (event: OptimizationStreamEvent) => void | Promise<void>;
}
export async function runStreamingOptimizationWorkflow({
jobId,
input,
factCard,
onEvent,
}: RunStreamingOptimizationWorkflowInput) {
await onEvent({
type: "draft_started",
job_id: jobId,
message: "正在生成优化草稿",
});
let article = await optimizeArticle({ input, factCard });
await onEvent({ type: "draft_ready", job_id: jobId, article });
await onEvent({
type: "qa_started",
job_id: jobId,
message: "正在检查质量",
});
let qaReport = await inspectQualityWithLlm({
article,
factCard,
platform: input.platform,
sourceImages: input.images,
});
await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport });
let rewriteRounds = 0;
while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
const nextRound = rewriteRounds + 1;
const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
await onEvent({
type: "rewrite_started",
job_id: jobId,
round: nextRound,
});
article = await rewriteFailedSections({ article, factCard, failedChecks });
rewriteRounds = nextRound;
await onEvent({
type: "rewrite_ready",
job_id: jobId,
round: nextRound,
article,
});
await onEvent({
type: "qa_started",
job_id: jobId,
message: `正在复检第 ${nextRound} 轮修复`,
});
qaReport = await inspectQualityWithLlm({
article,
factCard,
platform: input.platform,
sourceImages: input.images,
});
await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport });
}
return {
article,
qaReport,
rewriteRounds,
stoppedAfterMaxRewrites:
qaReport.overall_status === "fail" && rewriteRounds >= 2,
};
}
```
- [ ] **Step 4: Run streaming workflow tests and verify GREEN**
Run:
```bash
npm test -- src/lib/workflow/__tests__/streaming-optimizer.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 3**
```bash
git add src/lib/workflow/streaming-optimizer.ts src/lib/workflow/__tests__/streaming-optimizer.test.ts
git commit -m "新增一键优化流式编排"
```
---
## Task 4: Streaming API Route
**Files:**
- Create: `src/app/api/jobs/optimize-stream/route.ts`
- Modify: `src/app/api/__tests__/jobs.test.ts`
- [ ] **Step 1: Add route imports and stream helpers to the API test**
In `src/app/api/__tests__/jobs.test.ts`, add this import with the other route imports:
```ts
import { POST as optimizeStream } from "../jobs/optimize-stream/route";
```
Add these helper types after `OptimizeJobResponse`:
```ts
interface StreamEventResponse {
type: string;
job_id?: string;
job?: { id: string };
fact_card?: { company_full_name: string; confirmed_by_user?: boolean };
article?: { title: string; body_markdown?: string };
optimized_article?: { title: string };
qa_report?: { overall_status?: string };
export_paths?: Record<string, string>;
stage?: string;
error?: string;
}
```
Add this helper near the bottom of the file:
```ts
async function streamEvents(response: Response) {
const text = await response.text();
return text
.split(/\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as StreamEventResponse);
}
```
- [ ] **Step 2: Write failing route tests**
Append these tests inside `describe("job API routes", () => { ... })`:
```ts
it("streams a one-click optimization from body-only input", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockResolvedValueOnce({
title: "流式优化标题",
summary: "流式优化摘要。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_lines: "",
platform: "official_site",
user_instructions: "",
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toEqual([
"job_created",
"fact_card_ready",
"draft_started",
"draft_ready",
"qa_started",
"qa_ready",
"final_ready",
]);
expect(events.find((event) => event.type === "fact_card_ready")?.fact_card).toEqual(
expect.objectContaining({ confirmed_by_user: false }),
);
expect(events.find((event) => event.type === "final_ready")?.optimized_article?.title).toBe(
"流式优化标题",
);
});
it("uses an edited fact card without extracting a new one", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "使用编辑事实卡的标题",
summary: "使用编辑事实卡的摘要。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: ["title"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const response = await optimizeStream(
request({
body: "示例科技提供GEO内容优化服务。",
platform: "official_site",
fact_card: {
...validCandidateFactCard,
company_full_name: "",
uncertain_items: ["公司全称需要确认"],
confirmed_by_user: false,
},
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toContain("fact_card_ready");
expect(events.find((event) => event.type === "fact_card_ready")?.fact_card?.company_full_name).toBe(
"",
);
expect(llmMocks.generateValidatedJson).toHaveBeenCalledTimes(2);
expect(llmMocks.generateValidatedJson).not.toHaveBeenCalledWith(
expect.objectContaining({ task: "fact_extractor" }),
);
});
it("returns a Chinese validation error for empty stream input bodies", async () => {
const response = await optimizeStream(
request({
title: "",
body: " ",
platform: "official_site",
}),
);
const body = (await response.json()) as { error: string };
expect(response.status).toBe(400);
expect(body.error).toBe("请输入需要优化的文章内容");
});
it("streams failed events when the LLM fails after the job is created", async () => {
llmMocks.generateValidatedJson
.mockResolvedValueOnce(validCandidateFactCard)
.mockRejectedValueOnce(new Error("LLM provider error: timeout"));
const response = await optimizeStream(
request({
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
platform: "official_site",
}),
);
const events = await streamEvents(response);
expect(response.status).toBe(200);
expect(events.map((event) => event.type)).toEqual([
"job_created",
"fact_card_ready",
"draft_started",
"failed",
]);
expect(events.at(-1)).toEqual(
expect.objectContaining({
type: "failed",
stage: "draft",
error: "LLM provider error: timeout",
}),
);
});
```
- [ ] **Step 3: Run API tests and verify RED**
Run:
```bash
npm test -- src/app/api/__tests__/jobs.test.ts
```
Expected: FAIL because `src/app/api/jobs/optimize-stream/route.ts` does not exist.
- [ ] **Step 4: Implement streaming route**
Create `src/app/api/jobs/optimize-stream/route.ts`:
```ts
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
import { optimizationFactCardSchema } from "../../../../lib/domain/validation";
import { LlmValidationError } from "../../../../lib/llm/client";
import { getExportStoreFromRuntime } from "../../../../lib/workflow/export-store";
import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor";
import {
normalizeInput,
type RawArticleInput,
} from "../../../../lib/workflow/input-normalizer";
import {
encodeOptimizationStreamEvent,
type OptimizationStreamEvent,
type OptimizationStreamStage,
} from "../../../../lib/workflow/stream-events";
import { runStreamingOptimizationWorkflow } from "../../../../lib/workflow/streaming-optimizer";
interface OptimizeStreamRequest extends RawArticleInput {
fact_card?: unknown;
}
export async function POST(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
let payload: OptimizeStreamRequest;
try {
payload = (await request.json()) as OptimizeStreamRequest;
} catch {
return NextResponse.json({ error: "请求体不是合法 JSON" }, { status: 400 });
}
if (typeof payload.body !== "string" || payload.body.trim().length === 0) {
return NextResponse.json(
{ error: "请输入需要优化的文章内容" },
{ status: 400 },
);
}
let normalized: ReturnType<typeof normalizeInput>;
try {
normalized = normalizeInput(payload);
} catch (error) {
return jsonError(error, getErrorStatus(error));
}
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let jobId: string | undefined;
let stage: OptimizationStreamStage = "job";
function send(event: OptimizationStreamEvent) {
controller.enqueue(encoder.encode(encodeOptimizationStreamEvent(event)));
}
try {
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,
});
jobId = job.id;
send({ type: "job_created", job: { id: job.id } });
stage = "fact_card";
const factCard = payload.fact_card
? optimizationFactCardSchema.parse(payload.fact_card)
: optimizationFactCardSchema.parse(
await extractCandidateFactCard(normalized.articleInput),
);
const savedFactCard = await repository.saveFactCard(job.id, factCard);
send({
type: "fact_card_ready",
job_id: job.id,
fact_card: savedFactCard,
});
stage = "draft";
const result = await runStreamingOptimizationWorkflow({
jobId: job.id,
input: normalized.articleInput,
factCard: savedFactCard,
onEvent: (event) => {
if (event.type === "qa_started") stage = "qa";
if (event.type === "rewrite_started") stage = "rewrite";
send(event);
},
});
stage = "final";
const optimizedArticle = await repository.saveOptimizedArticle(
job.id,
result.article,
);
const qaReport = await repository.saveQaReport(
job.id,
optimizedArticle.revision ?? 1,
result.qaReport,
);
const exportStore = getExportStoreFromRuntime();
const exportPaths = await exportStore.writeJobExports({
jobId: job.id,
article: optimizedArticle,
qaReport,
});
await repository.updateArticleJob(job.id, {
status: "optimized",
export_paths: exportPaths,
});
send({
type: "final_ready",
job_id: job.id,
optimized_article: optimizedArticle,
qa_report: qaReport,
export_paths: exportPaths,
});
} catch (error) {
send({
type: "failed",
job_id: jobId,
stage,
error: error instanceof Error ? error.message : "优化失败",
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-cache, no-transform",
},
});
}
function jsonError(error: unknown, status: number) {
const message = error instanceof Error ? error.message : "Request failed";
return NextResponse.json({ error: message }, { status });
}
function getErrorStatus(error: unknown) {
if (error instanceof LlmValidationError) return 502;
if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502;
return 400;
}
```
- [ ] **Step 5: Run API tests and verify GREEN**
Run:
```bash
npm test -- src/app/api/__tests__/jobs.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit Task 4**
```bash
git add src/app/api/jobs/optimize-stream/route.ts src/app/api/__tests__/jobs.test.ts
git commit -m "新增一键流式优化接口"
```
---
## Task 5: Frontend Components For One-Click Streaming UI
**Files:**
- Modify: `src/components/article-input-form.tsx`
- Modify: `src/components/fact-card-editor.tsx`
- Modify: `src/components/optimized-preview.tsx`
- Modify: `src/components/__tests__/fact-card-editor.test.ts`
- Create: `src/components/__tests__/optimized-preview.test.tsx`
- Modify: `src/app/globals.css`
- [ ] **Step 1: Add compact fact-card utility tests**
In `src/components/__tests__/fact-card-editor.test.ts`, update the import:
```ts
import {
getFactCardSummary,
resolveUncertainItem,
} from "../fact-card-editor";
```
Append:
```ts
it("summarizes compact fact card fields for display", () => {
expect(getFactCardSummary(baseFactCard)).toEqual({
name: "示例科技有限公司",
product: "GEO内容优化平台",
industry: "GEO内容优化",
audience: "市场团队",
coreClaimCount: 1,
uncertainCount: 2,
});
});
it("falls back to short names when company full name is missing", () => {
expect(
getFactCardSummary({
...baseFactCard,
company_full_name: "",
company_short_names: ["示例科技"],
}).name,
).toBe("示例科技");
});
```
- [ ] **Step 2: Add optimized preview streaming tests**
Create `src/components/__tests__/optimized-preview.test.tsx`:
```tsx
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OptimizedPreview } from "../optimized-preview";
const article = {
title: "示例科技 GEO 内容优化方案",
summary: "面向市场团队的优化摘要。",
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
image_suggestions: [],
changed_sections: ["标题", "正文"],
requires_user_confirmation: [],
};
describe("OptimizedPreview streaming states", () => {
it("shows a cursor and stage text while streaming without article content", () => {
const html = renderToStaticMarkup(
<OptimizedPreview
article={null}
jobId={null}
streamStage="正在生成草稿"
streamStatus="running"
/>,
);
expect(html).toContain("正在生成草稿");
expect(html).toContain("▋");
});
it("renders streamed draft content before final completion", () => {
const html = renderToStaticMarkup(
<OptimizedPreview
article={null}
jobId="job_stream"
streamArticle={article}
streamStage="正在检查质量"
streamStatus="running"
/>,
);
expect(html).toContain("正在检查质量");
expect(html).toContain("示例科技 GEO 内容优化方案");
expect(html).toContain("示例科技提供GEO内容优化服务");
});
});
```
- [ ] **Step 3: Run component tests and verify RED**
Run:
```bash
npm test -- src/components/__tests__/fact-card-editor.test.ts src/components/__tests__/optimized-preview.test.tsx
```
Expected: FAIL because `getFactCardSummary` and streaming props do not exist.
- [ ] **Step 4: Update `ArticleInputForm` to one-click input**
In `src/components/article-input-form.tsx`, change `ArticleInputFormProps`:
```ts
interface ArticleInputFormProps {
value: ArticleInputPayload;
isSubmitting: boolean;
onChange: (value: ArticleInputPayload) => void;
onSubmit: () => void;
}
```
Keep the shape, but replace the JSX returned by the form with:
```tsx
<form className="panel stack" onSubmit={handleSubmit}>
<div className="panel-heading">
<span>文章输入</span>
<button disabled={isSubmitting || !value.body.trim()} type="submit">
{isSubmitting ? "优化中..." : "开始优化"}
</button>
</div>
<label>
<span>粘贴文章</span>
<textarea
required
className="body-input"
value={value.body}
onChange={(event) => update("body", event.target.value)}
/>
</label>
<details className="advanced-inputs">
<summary>更多选项</summary>
<label>
<span>标题(可选)</span>
<input
value={value.title}
onChange={(event) => update("title", event.target.value)}
/>
</label>
<label>
<span>图片描述或图片链接</span>
<textarea
value={value.image_lines}
onChange={(event) => update("image_lines", event.target.value)}
/>
</label>
<label>
<span>目标平台</span>
<select
value={value.platform}
onChange={(event) =>
update("platform", event.target.value as PublishPlatform)
}
>
{platforms.map((platform) => (
<option key={platform.value} value={platform.value}>
{platform.label}
</option>
))}
</select>
</label>
<label>
<span>用户要求</span>
<textarea
value={value.user_instructions}
onChange={(event) => update("user_instructions", event.target.value)}
/>
</label>
</details>
</form>
```
- [ ] **Step 5: Add compact fact-card summary**
In `src/components/fact-card-editor.tsx`, export this interface and function above `FactCardEditor`:
```ts
export interface FactCardSummary {
name: string;
product: string;
industry: string;
audience: string;
coreClaimCount: number;
uncertainCount: number;
}
export function getFactCardSummary(factCard: CandidateFactCard): FactCardSummary {
return {
name:
factCard.company_full_name ||
factCard.company_short_names[0] ||
factCard.brand_names[0] ||
"未识别公司",
product: factCard.product_names[0] || factCard.brand_names[0] || "未识别产品",
industry: factCard.target_industry || "未识别行业",
audience: factCard.target_audience || "未识别受众",
coreClaimCount: factCard.core_claims.length,
uncertainCount: factCard.uncertain_items.length,
};
}
```
Change props to remove confirmation from the main path:
```ts
interface FactCardEditorProps {
factCard: CandidateFactCard | null;
onChange: (factCard: CandidateFactCard) => void;
}
```
Remove `isSaving` and `onConfirm` from the function signature.
Inside the rendered non-empty section, replace the panel heading with a compact summary:
```tsx
const summary = getFactCardSummary(currentFactCard);
return (
<section className="panel stack fact-card-compact">
<div className="panel-heading">
<span>事实卡</span>
<span className={summary.uncertainCount > 0 ? "status-pill warn" : "status-pill pass"}>
{summary.uncertainCount > 0
? `${summary.uncertainCount} 项待确认`
: "可用于优化"}
</span>
</div>
<div className="fact-card-summary">
<strong>{summary.name}</strong>
<span>{summary.product}</span>
<span>{summary.industry}</span>
<span>{summary.audience}</span>
<span>{summary.coreClaimCount} 条核心事实</span>
</div>
<details>
<summary>展开编辑事实卡</summary>
<div className="stack fact-card-fields">
```
Wrap the existing labels and uncertain-items block inside that `<div>`, close `</div></details>` before the section closes, and remove the old `确认事实卡` button and the “请先处理待确认事项,再开始优化。” blocking copy. Keep `resolveUncertainItem` behavior for users who want to clean up uncertain items manually.
- [ ] **Step 6: Add streaming result props**
In `src/components/optimized-preview.tsx`, replace the props interface with:
```ts
type StreamStatus = "idle" | "running" | "completed" | "failed";
interface OptimizedPreviewProps {
article: OptimizedArticle | null;
jobId: string | null;
streamArticle?: OptimizedArticle | null;
streamStage?: string;
streamStatus?: StreamStatus;
}
```
At the top of `OptimizedPreview`, add:
```ts
const visibleArticle = article ?? streamArticle ?? null;
const isStreaming = streamStatus === "running";
if (!visibleArticle && isStreaming) {
return (
<section className="panel stack streaming-preview" aria-live="polite">
<div className="panel-heading">优化结果</div>
<div className="stream-stage">{streamStage ?? "正在优化"}</div>
<div className="stream-skeleton">
<span></span>
</div>
</section>
);
}
if (!visibleArticle) {
```
Replace all `article` references in the rest of the component with `visibleArticle`, and change the heading to:
```tsx
<div className="panel-heading">
<span>优化结果</span>
{isStreaming && <span className="stream-stage">{streamStage ?? "正在优化"}</span>}
</div>
```
Add the streaming class to `<article>`:
```tsx
<article className={isStreaming ? "preview breathing-preview" : "preview"}>
```
Keep export links available only when `jobId` exists.
- [ ] **Step 7: Add CSS for compact and streaming UI**
Append to `src/app/globals.css` before the media query:
```css
.advanced-inputs {
border-top: 1px solid #e5e9f0;
display: grid;
gap: 0.8rem;
padding-top: 0.8rem;
}
.advanced-inputs summary,
.fact-card-compact summary {
color: #172033;
cursor: pointer;
font-weight: 800;
}
.fact-card-summary {
border: 1px solid #e5e9f0;
border-radius: 8px;
display: grid;
gap: 0.35rem;
padding: 0.75rem;
}
.fact-card-summary strong {
color: #172033;
}
.fact-card-summary span {
color: #586174;
}
.fact-card-fields {
margin-top: 0.8rem;
}
.streaming-preview {
overflow: hidden;
}
.stream-stage {
color: #2f6fdd;
font-size: 0.82rem;
font-weight: 800;
}
.stream-skeleton {
animation: stream-breathe 1.6s ease-in-out infinite;
background: #f6f7f9;
border-radius: 8px;
color: #2f6fdd;
min-height: 12rem;
padding: 1rem;
}
.breathing-preview {
animation: stream-breathe 1.8s ease-in-out infinite;
}
@keyframes stream-breathe {
0%,
100% {
box-shadow: inset 3px 0 0 #2f6fdd33;
opacity: 0.88;
}
50% {
box-shadow: inset 3px 0 0 #2f6fdd;
opacity: 1;
}
}
```
- [ ] **Step 8: Run component tests and verify GREEN**
Run:
```bash
npm test -- src/components/__tests__/fact-card-editor.test.ts src/components/__tests__/optimized-preview.test.tsx
```
Expected: PASS.
- [ ] **Step 9: Commit Task 5**
```bash
git add src/components/article-input-form.tsx src/components/fact-card-editor.tsx src/components/optimized-preview.tsx src/components/__tests__/fact-card-editor.test.ts src/components/__tests__/optimized-preview.test.tsx src/app/globals.css
git commit -m "改造一键优化前端组件"
```
---
## Task 6: Homepage Stream Integration And E2E
**Files:**
- Modify: `src/app/page.tsx`
- Modify: `tests/e2e/mvp.spec.ts`
- [ ] **Step 1: Update the E2E test to target the new main path**
Replace `tests/e2e/mvp.spec.ts` with:
```ts
import { expect, test } from "@playwright/test";
test("中文界面可以一键流式生成优化文章和导出链接", async ({ page }) => {
await page.route("**/api/jobs/optimize-stream", async (route) => {
const events = [
{ type: "job_created", job: { id: "job_stream_e2e" } },
{
type: "fact_card_ready",
job_id: "job_stream_e2e",
fact_card: {
company_full_name: "Example Technology Co., Ltd.",
company_short_names: ["Example"],
brand_names: ["Example"],
product_names: ["Example GEO"],
target_industry: "GEO optimization",
target_audience: "Marketing teams",
experience_years: 8,
core_claims: ["8 years of GEO optimization experience"],
forbidden_claims: [],
image_topics: ["产品仪表盘截图"],
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: false,
},
},
{
type: "draft_started",
job_id: "job_stream_e2e",
message: "正在生成优化草稿",
},
{
type: "draft_ready",
job_id: "job_stream_e2e",
article: {
title: "Example GEO 内容优化方案",
summary: "面向市场团队的优化摘要。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
image_suggestions: [],
changed_sections: ["标题", "正文"],
requires_user_confirmation: [],
},
},
{
type: "qa_started",
job_id: "job_stream_e2e",
message: "正在检查质量",
},
{
type: "qa_ready",
job_id: "job_stream_e2e",
qa_report: {
overall_status: "pass",
checks: [],
},
},
{
type: "final_ready",
job_id: "job_stream_e2e",
optimized_article: {
job_id: "job_stream_e2e",
revision: 1,
title: "Example GEO 内容优化方案",
summary: "面向市场团队的优化摘要。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
image_suggestions: [],
changed_sections: ["标题", "正文"],
requires_user_confirmation: [],
},
qa_report: {
job_id: "job_stream_e2e",
revision: 1,
overall_status: "pass",
checks: [],
},
export_paths: {
"optimized.md": "optimized.md",
"optimized.docx": "optimized.docx",
"qa_report.json": "qa_report.json",
},
},
];
await route.fulfill({
status: 200,
contentType: "application/x-ndjson; charset=utf-8",
body: `${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
});
});
await page.goto("/");
await page.getByLabel("访问密钥").fill("local-dev-key");
await page
.getByLabel("粘贴文章")
.fill(
"Example Technology Co., Ltd. has 8 years of GEO optimization experience. Example GEO 帮助市场团队优化内容结构。",
);
await page.getByRole("button", { name: "开始优化" }).click();
await expect(page.getByText("事实卡")).toBeVisible();
await expect(page.getByText("Example Technology Co., Ltd.")).toBeVisible();
await expect(page.getByText("正在生成优化草稿")).toBeVisible();
await expect(page.getByText("Example GEO 内容优化方案")).toBeVisible();
await expect(page.getByRole("link", { name: "optimized.md" })).toBeVisible();
await expect(page.getByText("质量报告")).toBeVisible();
await expect(page.getByRole("link", { name: "optimized.docx" })).toBeVisible();
await expect(page.getByRole("link", { name: "qa_report.json" })).toBeVisible();
});
```
- [ ] **Step 2: Run E2E command and verify RED**
Run:
```bash
npx playwright test tests/e2e/mvp.spec.ts
```
Expected: FAIL because the homepage still has `标题` / `正文`, old analysis/confirmation workflow, and no stream integration.
- [ ] **Step 3: Replace page state and stream handling**
In `src/app/page.tsx`, replace the old `CreateJobResponse`, `ConfirmFactCardResponse`, `OptimizeJobResponse`, `ProgressResponse`, `WorkflowProgress`, `ProgressStep`, `canOptimize`, polling `useEffect`, `analyze`, `confirmFactCard`, and `optimize` code with stream state.
Use these imports:
```ts
import { useEffect, useRef, useState } from "react";
import {
ArticleInputForm,
type ArticleInputPayload,
} from "../components/article-input-form";
import { FactCardEditor } from "../components/fact-card-editor";
import { OptimizedPreview } from "../components/optimized-preview";
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
import { ProgressPanel } from "../components/progress-panel";
import { QaReportPanel } from "../components/qa-report-panel";
import type {
CandidateFactCard,
OptimizedArticle,
QaReport,
} from "../lib/domain/types";
import {
parseOptimizationStreamChunk,
type OptimizationStreamEvent,
} from "../lib/workflow/stream-events";
import type { ProgressAction } from "../lib/progress/progress";
```
Add these stream types near the top:
```ts
type StreamStatus = "idle" | "running" | "completed" | "failed";
```
Inside `Home`, replace stream-related state with:
```ts
const [jobId, setJobId] = useState<string | null>(null);
const [factCard, setFactCard] = useState<CandidateFactCard | null>(null);
const [optimizedArticle, setOptimizedArticle] =
useState<OptimizedArticle | null>(null);
const [streamArticle, setStreamArticle] = useState<OptimizedArticle | null>(null);
const [qaReport, setQaReport] = useState<QaReport | null>(null);
const [busyAction, setBusyAction] = useState<ProgressAction | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus>("idle");
const [streamStage, setStreamStage] = useState("");
const [message, setMessage] = useState<string>("");
const [apiAccessKey, setApiAccessKey] = useState("");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
```
Keep the elapsed timer effect. Remove the progress polling effect.
Add this event handler:
```ts
function applyStreamEvent(event: OptimizationStreamEvent) {
if (event.type === "job_created") {
setJobId(event.job.id);
setStreamStage("正在提取事实");
return;
}
if (event.type === "fact_card_ready") {
setJobId(event.job_id);
setFactCard(event.fact_card);
setStreamStage("正在生成草稿");
return;
}
if (event.type === "draft_started") {
setStreamStage(event.message);
return;
}
if (event.type === "draft_ready") {
setStreamArticle(event.article);
setStreamStage("正在检查质量");
return;
}
if (event.type === "qa_started") {
setStreamStage(event.message);
return;
}
if (event.type === "qa_ready") {
setQaReport(event.qa_report);
setStreamStage(
event.qa_report.overall_status === "fail"
? "正在定向修复"
: "正在整理结果",
);
return;
}
if (event.type === "rewrite_started") {
setStreamStage(`正在定向修复第 ${event.round} 轮`);
return;
}
if (event.type === "rewrite_ready") {
setStreamArticle(event.article);
setStreamStage(`第 ${event.round} 轮修复已生成`);
return;
}
if (event.type === "final_ready") {
setJobId(event.job_id);
setOptimizedArticle(event.optimized_article);
setStreamArticle(event.optimized_article);
setQaReport(event.qa_report);
setStreamStatus("completed");
setStreamStage("终稿完成");
setMessage(
event.qa_report.overall_status === "fail"
? "质检发现需要复核的问题,已保留导出文件。"
: "优化完成。",
);
return;
}
if (event.type === "failed") {
setStreamStatus("failed");
setStreamStage("优化失败");
setMessage(event.error);
}
}
```
Add this submit function:
```ts
async function startStreamingOptimization() {
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setElapsedSeconds(0);
setBusyAction("optimize");
setStreamStatus("running");
setStreamStage("正在提交文章");
setMessage("");
setLastTiming(null);
setStreamArticle(null);
setOptimizedArticle(null);
setQaReport(null);
try {
const response = await fetch("/api/jobs/optimize-stream", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
...input,
fact_card: factCard,
}),
signal: abortController.signal,
});
if (!response.ok || !response.body) {
const body = (await response.json().catch(() => ({}))) as ApiErrorResponse;
throw new ApiResponseError(body.error ?? "优化失败", body.timing);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let remainder = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
const parsed = parseOptimizationStreamChunk(
remainder,
decoder.decode(value, { stream: true }),
);
remainder = parsed.remainder;
parsed.events.forEach(applyStreamEvent);
}
const finalChunk = parseOptimizationStreamChunk(
remainder,
decoder.decode(),
);
finalChunk.events.forEach(applyStreamEvent);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
if (error instanceof ApiResponseError) {
setLastTiming(error.timing ?? null);
}
setStreamStatus("failed");
setStreamStage("优化失败");
setMessage(error instanceof Error ? error.message : "优化失败");
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
setBusyAction(null);
}
}
```
Add an unmount cleanup effect:
```ts
useEffect(() => {
return () => abortControllerRef.current?.abort();
}, []);
```
In JSX:
- Remove the topbar `开始优化` button.
- Pass `onSubmit={startStreamingOptimization}` to `ArticleInputForm`.
- Render `FactCardEditor` with only `factCard` and `onChange`:
```tsx
<FactCardEditor
factCard={factCard}
onChange={setFactCard}
/>
```
- Pass streaming props to `OptimizedPreview`:
```tsx
<OptimizedPreview
article={optimizedArticle}
jobId={jobId}
streamArticle={streamArticle}
streamStage={streamStage}
streamStatus={streamStatus}
/>
```
Keep `ProgressPanel` for elapsed time, but pass no `liveProgress` prop after Task 5 if the component signature still requires it, pass `liveProgress={null}`:
```tsx
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={null}
/>
```
- [ ] **Step 4: Run E2E and focused tests**
Run:
```bash
npm test -- src/lib/workflow/__tests__/stream-events.test.ts src/components/__tests__/fact-card-editor.test.ts src/components/__tests__/optimized-preview.test.tsx
npx playwright test tests/e2e/mvp.spec.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 6**
```bash
git add src/app/page.tsx tests/e2e/mvp.spec.ts
git commit -m "接入首页一键流式优化"
```
---
## Task 7: Full Verification And Cleanup
**Files:**
- Inspect all changed files.
- No planned source edits unless verification exposes failures.
- [ ] **Step 1: Run lint**
Run:
```bash
npm run lint
```
Expected: PASS with exit code 0.
- [ ] **Step 2: Run full test suite**
Run:
```bash
npm test
```
Expected: PASS with exit code 0.
- [ ] **Step 3: Run build**
Run:
```bash
npm run build
```
Expected: PASS with exit code 0.
- [ ] **Step 4: Run E2E**
Run:
```bash
npx playwright test tests/e2e/mvp.spec.ts
```
Expected: PASS with exit code 0.
- [ ] **Step 5: Check status and ignored files**
Run:
```bash
git status --short --ignored=matching
```
Expected:
- Only intentional tracked changes are present before the final commit.
- Ignored local files such as `.env.local`, `.next/`, `.open-next/`, `.wrangler/`, `data/app.db`, `data/exports/`, `deploy/*.toml`, and `.superpowers/` remain ignored.
- [ ] **Step 6: Run credential safety scan**
Run:
```bash
rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml'
```
Expected: no matches in tracked source files. If matches appear only in ignored/local files, do not commit them.
---
## Spec Coverage Self-Review
- One-click optimization: Tasks 4 and 6 implement `POST /api/jobs/optimize-stream` and homepage stream reading.
- Title/body simplification: Tasks 1 and 5 allow empty title and make `粘贴文章` the main input.
- Compact fact card: Task 5 implements summary display and expandable editing.
- No confirmation gate: Tasks 1, 4, and 6 use `OptimizationFactCard` and remove confirm dependency from the main path.
- Event streaming without token streaming: Tasks 2, 3, and 4 implement NDJSON stage events.
- Result-area streaming feel: Task 5 implements cursor, breathing preview, and draft/final display states.
- No fake body content: Task 5 only displays `streamArticle` or final article from parsed backend events.
- Edited fact-card rerun: Tasks 4 and 6 send current `fact_card` and skip extraction when present.
- Error handling: Task 4 covers empty body, provider failure, and failed stream events.
- Compatibility: Tasks 1 and 4 keep old routes while changing shared types compatibly.
- Verification: Task 7 covers lint, tests, build, E2E, status, and credential scan.