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

61 KiB

一键流式优化主流程 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:

import {
  articleInputSchema,
  confirmedFactCardSchema,
  candidateFactCardSchema,
  optimizationFactCardSchema,
  optimizedArticleSchema,
  qaReportSchema,
} from "../validation";

Append these tests inside describe("domain validation", () => { ... }):

  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:

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:

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:

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:

  ImageInput,
  OptimizationFactCard,
  OptimizedArticle,

Replace articleInputSchema with:

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:

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:

export interface RawArticleInput {
  title?: string;
  body: string;
  image_lines?: string;
  images?: ImageInput[];
  platform: PublishPlatform;
  user_instructions?: string;
}

Change the schema input construction to:

  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:

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:

import type {
  ImageInput,
  OptimizationFactCard,
  OptimizedArticle,
  PublishPlatform,
  QaCheck,
  QaReport,
} from "../domain/types";

The quality-inspector input should use:

factCard: OptimizationFactCard;

In src/lib/workflow/targeted-rewriter.ts, use:

import type { OptimizationFactCard, OptimizedArticle, QaCheck } from "../domain/types";

and:

factCard: OptimizationFactCard;

In src/lib/workflow/orchestrator.ts, use:

import type { ArticleInput, OptimizationFactCard } from "../domain/types";

and:

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:

import type {
  ArticleInput,
  OptimizationFactCard,
  OptimizedArticle,
  PublishPlatform,
  QaCheck,
} from "../domain/types";

The prompt parameter should be:

factCard: OptimizationFactCard
  • Step 7: Update repository fact-card method types

In src/lib/db/repository.ts, import OptimizationFactCard:

import type { OptimizationFactCard, OptimizedArticle, QaReport } from "../domain/types";

Change the repository methods:

  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:

import type {
  CalibrationEvent,
  PerformanceSnapshot,
  PublicationRecord,
  RubricVersion,
  ScoringRun,
} from "../calibration/types";
import type {
  ImageInput,
  OptimizationFactCard,
  OptimizedArticle,
  PublishPlatform,
  QaReport,
} from "../domain/types";

Change the function signature:

export function saveFactCard(
  dbPath: string | undefined,
  jobId: string,
  factCard: OptimizationFactCard,
) {

Change the saved source:

      source: factCard.confirmed_by_user
        ? "auto_extract_then_user_confirmed"
        : "auto_extract_for_optimization",

Change getFactCard parsing:

      ? { 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:

import type {
  ImageInput,
  OptimizationFactCard,
  OptimizedArticle,
  PublishPlatform,
  QaReport,
} from "../domain/types";

Change saveFactCard so the persisted source reflects whether the user explicitly confirmed the card:

          factCard.confirmed_by_user
            ? "auto_extract_then_user_confirmed"
            : "auto_extract_for_optimization",

Change getFactCard parsing:

        ? { 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", () => { ... }):

  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:

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

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:

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:

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:

npm test -- src/lib/workflow/__tests__/stream-events.test.ts

Expected: PASS.

  • Step 5: Commit Task 2
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:

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:

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:

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:

npm test -- src/lib/workflow/__tests__/streaming-optimizer.test.ts

Expected: PASS.

  • Step 5: Commit Task 3
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:

import { POST as optimizeStream } from "../jobs/optimize-stream/route";

Add these helper types after OptimizeJobResponse:

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:

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", () => { ... }):

  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:

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:

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:

npm test -- src/app/api/__tests__/jobs.test.ts

Expected: PASS.

  • Step 6: Commit Task 4
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:

import {
  getFactCardSummary,
  resolveUncertainItem,
} from "../fact-card-editor";

Append:

  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:

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:

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:

interface ArticleInputFormProps {
  value: ArticleInputPayload;
  isSubmitting: boolean;
  onChange: (value: ArticleInputPayload) => void;
  onSubmit: () => void;
}

Keep the shape, but replace the JSX returned by the form with:

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

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:

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:

  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:

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:

  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:

      <div className="panel-heading">
        <span>优化结果</span>
        {isStreaming && <span className="stream-stage">{streamStage ?? "正在优化"}</span>}
      </div>

Add the streaming class to <article>:

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

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

npm test -- src/components/__tests__/fact-card-editor.test.ts src/components/__tests__/optimized-preview.test.tsx

Expected: PASS.

  • Step 9: Commit Task 5
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:

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:

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:

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:

type StreamStatus = "idle" | "running" | "completed" | "failed";

Inside Home, replace stream-related state with:

  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:

  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:

  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:

  useEffect(() => {
    return () => abortControllerRef.current?.abort();
  }, []);

In JSX:

  • Remove the topbar 开始优化 button.
  • Pass onSubmit={startStreamingOptimization} to ArticleInputForm.
  • Render FactCardEditor with only factCard and onChange:
        <FactCardEditor
          factCard={factCard}
          onChange={setFactCard}
        />
  • Pass streaming props to OptimizedPreview:
        <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}:

      <ProgressPanel
        action={busyAction}
        elapsedSeconds={elapsedSeconds}
        lastTiming={lastTiming}
        liveProgress={null}
      />
  • Step 4: Run E2E and focused tests

Run:

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

npm run lint

Expected: PASS with exit code 0.

  • Step 2: Run full test suite

Run:

npm test

Expected: PASS with exit code 0.

  • Step 3: Run build

Run:

npm run build

Expected: PASS with exit code 0.

  • Step 4: Run E2E

Run:

npx playwright test tests/e2e/mvp.spec.ts

Expected: PASS with exit code 0.

  • Step 5: Check status and ignored files

Run:

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:

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.