Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-06-25-renwei-copy-optimization.md
T

36 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: Add an independent 普通文案优化 tab that optimizes pasted copy with renwei-writing-inspired rules, returning optimized text, change notes, and AI-taste checks.

Architecture: Add shared copy-optimization domain types and zod schemas, a prompt/workflow module that calls the existing validated LLM JSON path, a protected API route, and a focused React panel rendered behind a new home-page tab. The existing GEO article workflow stays the default tab and keeps its current state and behavior.

Tech Stack: Next.js App Router, React client components, TypeScript, Zod, existing generateValidatedJson LLM client, Vitest, Playwright.


File Structure

  • Modify src/lib/domain/types.ts: add copy optimization request/result interfaces and enums.
  • Modify src/lib/domain/validation.ts: add zod schemas for copy optimization request and result.
  • Modify src/lib/domain/__tests__/validation.test.ts: add schema tests.
  • Modify src/lib/llm/client.ts: add the renwei_copy_optimizer task name.
  • Modify src/lib/llm/prompts.ts: add renwei system prompt and prompt builder.
  • Modify src/lib/llm/__tests__/prompts.test.ts: add prompt coverage.
  • Create src/lib/workflow/renwei-copy-optimizer.ts: call the LLM with the prompt and result schema.
  • Create src/app/api/copy/renwei-optimize/route.ts: protected POST endpoint.
  • Create src/app/api/__tests__/copy-renwei.test.ts: API tests with mocked LLM.
  • Create src/components/renwei-copy-optimizer-panel.tsx: client UI for the new tool.
  • Modify src/app/page.tsx: add app-level tabs and render the new panel.
  • Modify src/app/globals.css: add small tab/result layout styles.
  • Create tests/e2e/renwei-copy.spec.ts: browser test using route interception for the new API.

Task 1: Domain Types And Validation

Files:

  • Modify: src/lib/domain/types.ts

  • Modify: src/lib/domain/validation.ts

  • Test: src/lib/domain/__tests__/validation.test.ts

  • Step 1: Write the failing validation tests

Append these tests inside the existing describe block in src/lib/domain/__tests__/validation.test.ts:

  it("accepts a trimmed copy optimization request", () => {
    const parsed = copyOptimizationRequestSchema.parse({
      source_text: "  我写了一段有点卡的文案  ",
      goal: "",
      intensity: "light",
      user_instructions: "  保留口语感  ",
    });

    expect(parsed).toEqual({
      source_text: "我写了一段有点卡的文案",
      goal: "保留原意,减少 AI 味",
      intensity: "light",
      user_instructions: "保留口语感",
    });
  });

  it("rejects empty copy optimization source text", () => {
    expect(() =>
      copyOptimizationRequestSchema.parse({
        source_text: "   ",
        intensity: "light",
      }),
    ).toThrow();
  });

  it("accepts structured copy optimization results", () => {
    const parsed = copyOptimizationResultSchema.parse({
      optimized_text: "我把句子顺了一下。",
      change_notes: [
        {
          original: "我把句子顺顺。",
          revised: "我把句子顺了一下。",
          reason: "修正口语里不顺的重复。",
          confidence: "confident",
          revertible: false,
        },
      ],
      ai_taste_checks: [
        {
          rule_id: "promotion_tone",
          status: "pass",
          evidence: "没有新增宣传词。",
          suggestion: "",
        },
      ],
      warnings: [],
    });

    expect(parsed.optimized_text).toBe("我把句子顺了一下。");
    expect(parsed.change_notes[0].confidence).toBe("confident");
    expect(parsed.ai_taste_checks[0].rule_id).toBe("promotion_tone");
  });

Also update the import at the top of the test file:

import {
  articleInputSchema,
  candidateFactCardSchema,
  confirmedFactCardSchema,
  copyOptimizationRequestSchema,
  copyOptimizationResultSchema,
  optimizedArticleSchema,
  qaReportSchema,
} from "../validation";
  • Step 2: Run the validation tests to verify RED

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts

Expected: FAIL with TypeScript or runtime errors saying copyOptimizationRequestSchema and copyOptimizationResultSchema are not exported.

  • Step 3: Add the domain types

Append to src/lib/domain/types.ts:

export type CopyOptimizationIntensity = "light" | "medium" | "conversational";

export type CopyChangeConfidence = "confident" | "uncertain";

export type CopyAiTasteRuleId =
  | "meaning_inflation"
  | "promotion_tone"
  | "formulaic_sentence"
  | "format_trace"
  | "chat_trace"
  | "filler_hedging";

export type CopyAiTasteStatus = "pass" | "warn";

export interface CopyOptimizationRequest {
  source_text: string;
  goal: string;
  intensity: CopyOptimizationIntensity;
  user_instructions: string;
}

export interface CopyChangeNote {
  original: string;
  revised: string;
  reason: string;
  confidence: CopyChangeConfidence;
  revertible: boolean;
}

export interface CopyAiTasteCheck {
  rule_id: CopyAiTasteRuleId;
  status: CopyAiTasteStatus;
  evidence: string;
  suggestion: string;
}

export interface CopyOptimizationResult {
  optimized_text: string;
  change_notes: CopyChangeNote[];
  ai_taste_checks: CopyAiTasteCheck[];
  warnings: string[];
}
  • Step 4: Add the zod schemas

In src/lib/domain/validation.ts, add the new imported type names:

  CopyAiTasteCheck,
  CopyChangeNote,
  CopyOptimizationRequest,
  CopyOptimizationResult,

Then append these schemas near the other exported schemas:

export const copyOptimizationIntensitySchema = z.enum([
  "light",
  "medium",
  "conversational",
]);

export const copyOptimizationRequestSchema = z.object({
  source_text: z.string().trim().min(1),
  goal: z
    .preprocess((value) => {
      if (typeof value !== "string") return value;
      const trimmed = value.trim();
      return trimmed.length > 0 ? trimmed : "保留原意,减少 AI 味";
    }, z.string().trim().min(1))
    .default("保留原意,减少 AI 味"),
  intensity: copyOptimizationIntensitySchema.default("light"),
  user_instructions: z.string().trim().default(""),
}) satisfies z.ZodType<CopyOptimizationRequest>;

const copyChangeNoteSchema = z.object({
  original: requiredLlmStringSchema,
  revised: requiredLlmStringSchema,
  reason: requiredLlmStringSchema,
  confidence: z.enum(["confident", "uncertain"]),
  revertible: z.boolean(),
}) satisfies z.ZodType<CopyChangeNote>;

const copyAiTasteCheckSchema = z.object({
  rule_id: z.enum([
    "meaning_inflation",
    "promotion_tone",
    "formulaic_sentence",
    "format_trace",
    "chat_trace",
    "filler_hedging",
  ]),
  status: z.enum(["pass", "warn"]),
  evidence: requiredLlmStringSchema,
  suggestion: optionalLlmStringSchema,
}) satisfies z.ZodType<CopyAiTasteCheck>;

export const copyOptimizationResultSchema = z.object({
  optimized_text: requiredLlmStringSchema,
  change_notes: z.array(copyChangeNoteSchema).default([]),
  ai_taste_checks: z.array(copyAiTasteCheckSchema).default([]),
  warnings: stringListSchema,
}) satisfies z.ZodType<CopyOptimizationResult>;
  • Step 5: Run the validation tests to verify GREEN

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts

Expected: PASS.

  • Step 6: Commit Task 1
git add src/lib/domain/types.ts src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
git commit -m "新增普通文案优化数据校验"

Task 2: Prompt Builder And Workflow

Files:

  • Modify: src/lib/llm/client.ts

  • Modify: src/lib/llm/prompts.ts

  • Modify: src/lib/llm/__tests__/prompts.test.ts

  • Create: src/lib/workflow/renwei-copy-optimizer.ts

  • Test: src/lib/workflow/__tests__/llm-integration.test.ts

  • Step 1: Write the failing prompt tests

Update imports in src/lib/llm/__tests__/prompts.test.ts:

  RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
  buildRenweiCopyOptimizationPrompt,

Append these tests:

  it("renwei copy prompt keeps the author present and defaults to small edits", () => {
    const prompt = `${RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT}\n${buildRenweiCopyOptimizationPrompt({
      source_text: "我观察到大家越来越难进入心流了。",
      goal: "保留原意,减少 AI 味",
      intensity: "light",
      user_instructions: "保留作者的口语感。",
    })}`;

    expect(prompt).toContain("少动");
    expect(prompt).toContain("保留作者");
    expect(prompt).toContain("手迹");
    expect(prompt).toContain("不凭空新增时间、地点、数字、案例");
    expect(prompt).toContain("不写过度金句");
    expect(prompt).toContain("逐处说明");
    expect(prompt).toContain("只检查被改动句子");
    expect(prompt).toContain("source_text");
  });

  it("renwei copy prompt describes all result fields and intensity modes", () => {
    const prompt = buildRenweiCopyOptimizationPrompt({
      source_text: "这是一段文案。",
      goal: "更自然",
      intensity: "conversational",
      user_instructions: "",
    });

    expect(prompt).toContain("optimized_text");
    expect(prompt).toContain("change_notes");
    expect(prompt).toContain("ai_taste_checks");
    expect(prompt).toContain("warnings");
    expect(prompt).toContain("light");
    expect(prompt).toContain("medium");
    expect(prompt).toContain("conversational");
  });
  • Step 2: Run prompt tests to verify RED

Run:

npm test -- src/lib/llm/__tests__/prompts.test.ts

Expected: FAIL because RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT and buildRenweiCopyOptimizationPrompt are not exported.

  • Step 3: Write the failing workflow test

In src/lib/workflow/__tests__/llm-integration.test.ts, update the hoisted LLM mock tests by importing:

import { optimizeRenweiCopy } from "../renwei-copy-optimizer";

Append this test:

  it("uses validated LLM output for renwei copy optimization", async () => {
    llmMocks.generateValidatedJson.mockResolvedValueOnce({
      optimized_text: "我观察到大家越来越难进入心流了。",
      change_notes: [
        {
          original: "我观察到大家越来越难进入心流",
          revised: "我观察到大家越来越难进入心流了。",
          reason: "补足句尾语气,让句子自然收住。",
          confidence: "confident",
          revertible: false,
        },
      ],
      ai_taste_checks: [
        {
          rule_id: "promotion_tone",
          status: "pass",
          evidence: "没有新增宣传腔。",
          suggestion: "",
        },
      ],
      warnings: [],
    });

    const result = await optimizeRenweiCopy({
      source_text: "我观察到大家越来越难进入心流",
      goal: "保留原意,减少 AI 味",
      intensity: "light",
      user_instructions: "",
    });

    expect(result.optimized_text).toBe("我观察到大家越来越难进入心流了。");
    expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
      expect.objectContaining({
        task: "renwei_copy_optimizer",
        temperature: 0.2,
      }),
    );
  });
  • Step 4: Run workflow test to verify RED

Run:

npm test -- src/lib/workflow/__tests__/llm-integration.test.ts -t "renwei copy"

Expected: FAIL because ../renwei-copy-optimizer does not exist or task type does not accept renwei_copy_optimizer.

  • Step 5: Add the LLM task name

Modify src/lib/llm/client.ts:

export type LlmTaskName =
  | "unknown"
  | "fact_extractor"
  | "article_optimizer"
  | "quality_inspector"
  | "targeted_rewriter"
  | "renwei_copy_optimizer";
  • Step 6: Add prompt constants and builder

Append to src/lib/llm/prompts.ts:

import type { CopyOptimizationRequest } from "../domain/types";

If the file already has a type import from ../domain/types, merge CopyOptimizationRequest into that existing import.

Then add:

const RENWEI_OUTPUT_CONTRACT = [
  "Output type contract:",
  "- optimized_text must be a string.",
  "- change_notes must be an array of { original, revised, reason, confidence, revertible }.",
  "- confidence must be confident or uncertain.",
  "- revertible must be boolean.",
  "- ai_taste_checks must be an array of { rule_id, status, evidence, suggestion }.",
  "- rule_id must be one of meaning_inflation, promotion_tone, formulaic_sentence, format_trace, chat_trace, filler_hedging.",
  "- status must be pass or warn.",
  "- warnings must be string[].",
  "Example:",
  JSON.stringify(
    {
      optimized_text: "我把句子顺了一下。",
      change_notes: [
        {
          original: "我把句子顺顺。",
          revised: "我把句子顺了一下。",
          reason: "修掉重复,让句子自然收住。",
          confidence: "confident",
          revertible: false,
        },
      ],
      ai_taste_checks: [
        {
          rule_id: "promotion_tone",
          status: "pass",
          evidence: "没有新增宣传腔。",
          suggestion: "",
        },
      ],
      warnings: [],
    },
    null,
    2,
  ),
].join("\n");

export const RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT = [
  "你是中文普通文案编辑,不是营销代笔工具。",
  "目标是让文字更顺,但改完后作者还在。",
  "硬性规则:少动,只改真正打绊的地方;保留作者的位置、语气、口头习惯和可识别手迹。",
  "不得凭空新增时间、地点、数字、案例、情绪、场景、资质、效果承诺或没有来源的事实。",
  "不要把普通句子改成宣传腔、排比、格言、万能展望、意义拔高或过度金句。",
  "拿不准时白描,不拔高;拿不准的改动必须标记为 uncertain 且 revertible 为 true。",
  "改后只检查被改动句子是否引入 AI 味,并逐处说明改了什么、为什么改。",
  JSON_ONLY_PROMPT,
].join(" ");

function describeRenweiIntensity(intensity: CopyOptimizationRequest["intensity"]) {
  const descriptions = {
    light: "light / 轻微整理:尽量只修顺病句、错别字、明显卡顿。",
    medium: "medium / 适度润色:允许调整句序和连接,但不改变作者表达的粗糙感。",
    conversational:
      "conversational / 更口语自然:让口吻更像真人说话,但不凭空增加表演性口语。",
  } satisfies Record<CopyOptimizationRequest["intensity"], string>;
  return descriptions[intensity];
}

export function buildRenweiCopyOptimizationPrompt(input: CopyOptimizationRequest) {
  return [
    "Return a CopyOptimizationResult JSON object with these exact keys:",
    "optimized_text, change_notes, ai_taste_checks, warnings.",
    "",
    RENWEI_OUTPUT_CONTRACT,
    "",
    "强度选项:",
    "- light:轻微整理。",
    "- medium:适度润色。",
    "- conversational:更口语自然。",
    `当前修改强度:${describeRenweiIntensity(input.intensity)}`,
    "",
    "改稿要求:",
    "- 默认少动,不追求更漂亮,只清掉绊脚处。",
    "- 毛边先假设是作者手迹,不要机械删口语、停顿或重复。",
    "- 不写“不是X,而是Y”、排比三连、万能展望、宣传腔或假深刻句式。",
    "- 不使用破折号作为装饰性解释。",
    "- 必须逐处说明改动;未改动的句子不要编造改动说明。",
    "- AI 味检查只检查被改动句子。",
    "",
    "User request:",
    JSON.stringify(
      {
        goal: input.goal,
        intensity: input.intensity,
        user_instructions: input.user_instructions,
      },
      null,
      2,
    ),
    "",
    "source_text:",
    input.source_text,
  ].join("\n");
}
  • Step 7: Add the workflow module

Create src/lib/workflow/renwei-copy-optimizer.ts:

import type {
  CopyOptimizationRequest,
  CopyOptimizationResult,
} from "../domain/types";
import { copyOptimizationResultSchema } from "../domain/validation";
import { generateValidatedJson } from "../llm/client";
import {
  RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
  buildRenweiCopyOptimizationPrompt,
} from "../llm/prompts";

export async function optimizeRenweiCopy(
  input: CopyOptimizationRequest,
): Promise<CopyOptimizationResult> {
  return generateValidatedJson({
    schema: copyOptimizationResultSchema,
    system: RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
    prompt: buildRenweiCopyOptimizationPrompt(input),
    temperature: 0.2,
    task: "renwei_copy_optimizer",
  });
}
  • Step 8: Run prompt and workflow tests to verify GREEN

Run:

npm test -- src/lib/llm/__tests__/prompts.test.ts src/lib/workflow/__tests__/llm-integration.test.ts

Expected: PASS.

  • Step 9: Commit Task 2
git add src/lib/llm/client.ts src/lib/llm/prompts.ts src/lib/llm/__tests__/prompts.test.ts src/lib/workflow/renwei-copy-optimizer.ts src/lib/workflow/__tests__/llm-integration.test.ts
git commit -m "新增人味儿文案优化提示词"

Task 3: Protected API Route

Files:

  • Create: src/app/api/copy/renwei-optimize/route.ts

  • Create: src/app/api/__tests__/copy-renwei.test.ts

  • Step 1: Write the failing API route tests

Create src/app/api/__tests__/copy-renwei.test.ts:

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const llmMocks = vi.hoisted(() => ({
  generateValidatedJson: vi.fn(),
}));

vi.mock("../../../lib/llm/client", async () => {
  const actual = await vi.importActual<typeof import("../../../lib/llm/client")>(
    "../../../lib/llm/client",
  );
  return {
    ...actual,
    generateValidatedJson: llmMocks.generateValidatedJson,
  };
});

import { POST as optimizeCopy } from "../copy/renwei-optimize/route";

describe("renwei copy optimization API route", () => {
  const originalApiKey = process.env.API_ACCESS_KEY;
  const originalAuthDisabled = process.env.API_AUTH_DISABLED;

  beforeEach(() => {
    process.env.API_ACCESS_KEY = "test-key";
    process.env.API_AUTH_DISABLED = "false";
  });

  afterEach(() => {
    process.env.API_ACCESS_KEY = originalApiKey;
    process.env.API_AUTH_DISABLED = originalAuthDisabled;
    llmMocks.generateValidatedJson.mockReset();
  });

  it("rejects requests without the access key", async () => {
    const response = await optimizeCopy(
      request(
        {
          source_text: "这是一段普通文案。",
          intensity: "light",
        },
        { apiKey: null },
      ),
    );

    expect(response.status).toBe(401);
  });

  it("returns 400 for empty source text", async () => {
    const response = await optimizeCopy(
      request({
        source_text: "   ",
        intensity: "light",
      }),
    );
    const body = (await response.json()) as { error: string };

    expect(response.status).toBe(400);
    expect(body.error).toBe("请输入需要优化的文案");
  });

  it("returns structured copy optimization results", async () => {
    llmMocks.generateValidatedJson.mockResolvedValueOnce({
      optimized_text: "我把这段文案顺了一下。",
      change_notes: [
        {
          original: "我把这段文案顺顺。",
          revised: "我把这段文案顺了一下。",
          reason: "修正重复表达。",
          confidence: "confident",
          revertible: false,
        },
      ],
      ai_taste_checks: [
        {
          rule_id: "promotion_tone",
          status: "pass",
          evidence: "没有新增宣传腔。",
          suggestion: "",
        },
      ],
      warnings: [],
    });

    const response = await optimizeCopy(
      request({
        source_text: "我把这段文案顺顺。",
        goal: "",
        intensity: "light",
        user_instructions: "保留口语。",
      }),
    );
    const body = (await response.json()) as {
      result: { optimized_text: string; change_notes: unknown[] };
    };

    expect(response.status).toBe(200);
    expect(body.result.optimized_text).toBe("我把这段文案顺了一下。");
    expect(body.result.change_notes).toHaveLength(1);
    expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
      expect.objectContaining({
        task: "renwei_copy_optimizer",
      }),
    );
  });

  it("surfaces LLM failures as a 502", async () => {
    llmMocks.generateValidatedJson.mockRejectedValueOnce(
      new Error("LLM response failed schema validation: optimized_text"),
    );

    const response = await optimizeCopy(
      request({
        source_text: "这是一段普通文案。",
        intensity: "light",
      }),
    );
    const body = (await response.json()) as { error: string };

    expect(response.status).toBe(502);
    expect(body.error).toBe(
      "LLM response failed schema validation: optimized_text",
    );
  });
});

function request(body: unknown, options: { apiKey?: string | null } = {}) {
  const headers: Record<string, string> = { "content-type": "application/json" };
  const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey;
  if (apiKey) {
    headers["x-api-key"] = apiKey;
  }

  return new Request("http://localhost/api/copy/renwei-optimize", {
    method: "POST",
    body: JSON.stringify(body),
    headers,
  });
}
  • Step 2: Run API tests to verify RED

Run:

npm test -- src/app/api/__tests__/copy-renwei.test.ts

Expected: FAIL because ../copy/renwei-optimize/route does not exist.

  • Step 3: Add the route implementation

Create src/app/api/copy/renwei-optimize/route.ts:

import { NextResponse } from "next/server";
import { z } from "zod";

import { requireApiAccess } from "../../../../lib/api/auth";
import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
import { LlmValidationError } from "../../../../lib/llm/client";
import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer";

export async function POST(request: Request) {
  const access = requireApiAccess(request);
  if (!access.ok) {
    return access.response;
  }

  try {
    const payload = copyOptimizationRequestSchema.parse(await request.json());
    const result = await optimizeRenweiCopy(payload);
    return NextResponse.json({ result });
  } catch (error) {
    return jsonError(error, getErrorStatus(error));
  }
}

function jsonError(error: unknown, status: number) {
  const message =
    error instanceof z.ZodError
      ? "请输入需要优化的文案"
      : error instanceof Error
        ? error.message
        : "文案优化失败";

  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 4: Run API tests to verify GREEN

Run:

npm test -- src/app/api/__tests__/copy-renwei.test.ts

Expected: PASS.

  • Step 5: Commit Task 3
git add src/app/api/copy/renwei-optimize/route.ts src/app/api/__tests__/copy-renwei.test.ts
git commit -m "新增普通文案优化接口"

Task 4: UI Tab And Panel

Files:

  • Create: src/components/renwei-copy-optimizer-panel.tsx

  • Modify: src/app/page.tsx

  • Modify: src/app/globals.css

  • Create: tests/e2e/renwei-copy.spec.ts

  • Step 1: Write the failing E2E test

Create tests/e2e/renwei-copy.spec.ts:

import { expect, test } from "@playwright/test";

test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
  await page.route("**/api/copy/renwei-optimize", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        result: {
          optimized_text: "我观察到大家越来越难进入心流了。",
          change_notes: [
            {
              original: "我观察到大家越来越难进入心流",
              revised: "我观察到大家越来越难进入心流了。",
              reason: "补足句尾语气,让句子自然收住。",
              confidence: "confident",
              revertible: false,
            },
          ],
          ai_taste_checks: [
            {
              rule_id: "promotion_tone",
              status: "pass",
              evidence: "没有新增宣传腔。",
              suggestion: "",
            },
          ],
          warnings: [],
        },
      }),
    });
  });

  await page.goto("/");
  await expect(page.getByRole("heading", { name: "GEO 智能文章优化器" })).toBeVisible();
  await expect(page.getByText("文章输入")).toBeVisible();

  await page.getByRole("button", { name: "普通文案优化" }).click();
  await expect(page.getByLabel("原始文案")).toBeVisible();

  await page.getByLabel("访问密钥").fill("local-dev-key");
  await page
    .getByLabel("原始文案")
    .fill("我观察到大家越来越难进入心流");
  await page.getByRole("button", { name: "优化文案" }).click();

  await expect(page.getByText("优化后文案")).toBeVisible();
  await expect(page.getByText("我观察到大家越来越难进入心流了。")).toBeVisible();
  await expect(page.getByText("改动说明")).toBeVisible();
  await expect(page.getByText("AI 味检查")).toBeVisible();
});
  • Step 2: Run E2E test to verify RED

Run:

npx playwright test tests/e2e/renwei-copy.spec.ts

Expected: FAIL because the 普通文案优化 tab does not exist.

  • Step 3: Create the client panel

Create src/components/renwei-copy-optimizer-panel.tsx:

"use client";

import { useState, type FormEvent } from "react";

import type {
  CopyOptimizationIntensity,
  CopyOptimizationResult,
} from "../lib/domain/types";

interface RenweiCopyOptimizerPanelProps {
  apiAccessKey: string;
}

interface CopyOptimizeResponse {
  result?: CopyOptimizationResult;
  error?: string;
}

const intensityOptions: Array<{
  value: CopyOptimizationIntensity;
  label: string;
}> = [
  { value: "light", label: "轻微整理" },
  { value: "medium", label: "适度润色" },
  { value: "conversational", label: "更口语自然" },
];

export function RenweiCopyOptimizerPanel({
  apiAccessKey,
}: RenweiCopyOptimizerPanelProps) {
  const [sourceText, setSourceText] = useState("");
  const [goal, setGoal] = useState("保留原意,减少 AI 味");
  const [intensity, setIntensity] =
    useState<CopyOptimizationIntensity>("light");
  const [userInstructions, setUserInstructions] = useState("");
  const [result, setResult] = useState<CopyOptimizationResult | null>(null);
  const [message, setMessage] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setIsSubmitting(true);
    setMessage("");
    setResult(null);

    try {
      const response = await fetch("/api/copy/renwei-optimize", {
        method: "POST",
        headers: apiHeaders(apiAccessKey),
        body: JSON.stringify({
          source_text: sourceText,
          goal,
          intensity,
          user_instructions: userInstructions,
        }),
      });
      const body = (await response.json()) as CopyOptimizeResponse;
      if (!response.ok || !body.result) {
        throw new Error(body.error ?? "文案优化失败");
      }
      setResult(body.result);
      setMessage("文案优化完成。");
    } catch (error) {
      setMessage(error instanceof Error ? error.message : "文案优化失败");
    } finally {
      setIsSubmitting(false);
    }
  }

  async function copyResult() {
    if (!result?.optimized_text) return;
    await navigator.clipboard.writeText(result.optimized_text);
    setMessage("已复制优化结果。");
  }

  return (
    <section className="copy-optimizer-grid">
      <form className="panel stack" onSubmit={submit}>
        <div className="panel-heading">
          <span>普通文案优化</span>
          <button disabled={isSubmitting} type="submit">
            {isSubmitting ? "优化中..." : "优化文案"}
          </button>
        </div>
        <label>
          <span>原始文案</span>
          <textarea
            className="body-input"
            required
            value={sourceText}
            onChange={(event) => setSourceText(event.target.value)}
          />
        </label>
        <label>
          <span>优化目标</span>
          <input
            value={goal}
            onChange={(event) => setGoal(event.target.value)}
          />
        </label>
        <label>
          <span>修改强度</span>
          <select
            value={intensity}
            onChange={(event) =>
              setIntensity(event.target.value as CopyOptimizationIntensity)
            }
          >
            {intensityOptions.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
        </label>
        <label>
          <span>补充要求</span>
          <textarea
            value={userInstructions}
            onChange={(event) => setUserInstructions(event.target.value)}
          />
        </label>
        {message ? <p className="status-text">{message}</p> : null}
      </form>

      <section className="panel stack">
        <div className="panel-heading">
          <span>优化后文案</span>
          <button disabled={!result} onClick={copyResult} type="button">
            复制结果
          </button>
        </div>
        {result ? (
          <>
            <pre className="copy-result">{result.optimized_text}</pre>
            <section className="stack">
              <h3>改动说明</h3>
              <ul className="qa-list">
                {result.change_notes.map((note, index) => (
                  <li className="qa-item" key={`${note.original}-${index}`}>
                    <p>{note.reason}</p>
                    <small>原句:{note.original}</small>
                    <small>改后:{note.revised}</small>
                    {note.revertible ? <em>这处可还原。</em> : null}
                  </li>
                ))}
              </ul>
            </section>
            <section className="stack">
              <h3>AI 味检查</h3>
              <ul className="qa-list">
                {result.ai_taste_checks.map((check) => (
                  <li className="qa-item" key={check.rule_id}>
                    <p>
                      <span className={`status-pill ${check.status}`}>
                        {check.status}
                      </span>
                    </p>
                    <small>{check.evidence}</small>
                    {check.suggestion ? <small>{check.suggestion}</small> : null}
                  </li>
                ))}
              </ul>
            </section>
            {result.warnings.length > 0 ? (
              <ul className="calibration-observations">
                {result.warnings.map((warning) => (
                  <li key={warning}>{warning}</li>
                ))}
              </ul>
            ) : null}
          </>
        ) : (
          <p className="empty-panel">优化结果会显示在这里。</p>
        )}
      </section>
    </section>
  );
}

function apiHeaders(apiAccessKey: string) {
  const headers: Record<string, string> = { "content-type": "application/json" };
  if (apiAccessKey) {
    headers["x-api-key"] = apiAccessKey;
  }
  return headers;
}
  • Step 4: Add tabs to the home page

In src/app/page.tsx, import the component:

import { RenweiCopyOptimizerPanel } from "../components/renwei-copy-optimizer-panel";

Add state inside Home:

  const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");

Replace the single <div className="workflow-grid">...</div> block with:

      <nav className="app-tabs" aria-label="功能标签">
        <button
          className={activeTab === "geo" ? "active-tab" : undefined}
          onClick={() => setActiveTab("geo")}
          type="button"
        >
          GEO 文章优化
        </button>
        <button
          className={activeTab === "copy" ? "active-tab" : undefined}
          onClick={() => setActiveTab("copy")}
          type="button"
        >
          普通文案优化
        </button>
      </nav>
      {activeTab === "geo" ? (
        <div className="workflow-grid">
          <ArticleInputForm
            isSubmitting={busyAction === "analyze"}
            value={input}
            onChange={setInput}
            onSubmit={analyze}
          />
          <FactCardEditor
            factCard={factCard}
            isSaving={busyAction === "confirm"}
            onChange={setFactCard}
            onConfirm={confirmFactCard}
          />
          <OptimizedPreview
            article={optimizedArticle}
            jobId={jobId}
          />
          <QaReportPanel report={qaReport} />
          <PerformanceCalibrationPanel
            apiAccessKey={apiAccessKey}
            jobId={jobId}
            key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
            optimizedRevision={optimizedArticle?.revision ?? null}
          />
        </div>
      ) : (
        <RenweiCopyOptimizerPanel apiAccessKey={apiAccessKey} />
      )}
  • Step 5: Add styles

Append to src/app/globals.css:

.app-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.app-tabs button {
  background: #ffffff;
  color: #172033;
}

.app-tabs button.active-tab {
  background: #172033;
  color: #ffffff;
}

.copy-optimizer-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}

.copy-result {
  background: #f6f7f9;
  border: 1px solid #e5e9f0;
  border-radius: 6px;
  color: #172033;
  margin: 0;
  min-height: 10rem;
  overflow: auto;
  padding: 0.8rem;
  white-space: pre-wrap;
}

Also extend the existing mobile media query:

  .copy-optimizer-grid {
    grid-template-columns: 1fr;
  }
  • Step 6: Run E2E test to verify GREEN

Run:

npx playwright test tests/e2e/renwei-copy.spec.ts

Expected: PASS.

  • Step 7: Commit Task 4
git add src/components/renwei-copy-optimizer-panel.tsx src/app/page.tsx src/app/globals.css tests/e2e/renwei-copy.spec.ts
git commit -m "新增普通文案优化标签页"

Task 5: Full Verification

Files:

  • No new files.

  • Step 1: Run lint

Run:

npm run lint

Expected: PASS with no ESLint errors.

  • Step 2: Run unit and API tests

Run:

npm test

Expected: PASS.

  • Step 3: Run production build

Run:

npm run build

Expected: PASS and Next.js build completes.

  • Step 4: Run targeted E2E

Run:

npx playwright test tests/e2e/renwei-copy.spec.ts

Expected: PASS.

  • Step 5: Check public-repo safety before any push

Run:

git status --short --ignored=matching
rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml'

Expected: no accidental credentials in tracked changes.

  • Step 6: Confirm verification left no uncommitted fixes

Run:

git status --short

Expected: no uncommitted files from the implementation tasks. If this command prints implementation files, return to the task that introduced them, add a specific regression test if the fix changes behavior, rerun the relevant verification command, and commit those exact files with the Chinese message 修复普通文案优化验证问题.

Self-Review

  • Spec coverage: Tasks 1-4 cover shared data models, prompt rules, API, frontend tab, result display, copy button, and tests. Task 5 covers lint, unit tests, build, E2E, and credential scan.
  • Scope check: The plan does not add history, exports, database tables, publishing calibration, multi-turn editing, or vendored renwei-writing content.
  • Type consistency: CopyOptimizationRequest, CopyOptimizationResult, CopyChangeNote, CopyAiTasteCheck, copyOptimizationRequestSchema, copyOptimizationResultSchema, RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT, buildRenweiCopyOptimizationPrompt, and optimizeRenweiCopy use consistent names across tasks.