diff --git a/docs/superpowers/plans/2026-07-01-one-click-streaming-optimization.md b/docs/superpowers/plans/2026-07-01-one-click-streaming-optimization.md new file mode 100644 index 0000000..974a1ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-one-click-streaming-optimization.md @@ -0,0 +1,2142 @@ +# 一键流式优化主流程 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; +``` + +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; +``` + +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; +} +``` + +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(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(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; + } + | { + 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; +} + +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; + 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; + try { + normalized = normalizeInput(payload); + } catch (error) { + return jsonError(error, getErrorStatus(error)); + } + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + 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( + , + ); + + expect(html).toContain("正在生成草稿"); + expect(html).toContain("▋"); + }); + + it("renders streamed draft content before final completion", () => { + const html = renderToStaticMarkup( + , + ); + + 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 +
+
+ 文章输入 + +
+