# 一键流式优化主流程 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
文章输入