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

1240 lines
36 KiB
Markdown

# 普通文案人味儿优化 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 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`:
```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:
```ts
import {
articleInputSchema,
candidateFactCardSchema,
confirmedFactCardSchema,
copyOptimizationRequestSchema,
copyOptimizationResultSchema,
optimizedArticleSchema,
qaReportSchema,
} from "../validation";
```
- [ ] **Step 2: Run the validation tests to verify RED**
Run:
```bash
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`:
```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:
```ts
CopyAiTasteCheck,
CopyChangeNote,
CopyOptimizationRequest,
CopyOptimizationResult,
```
Then append these schemas near the other exported schemas:
```ts
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:
```bash
npm test -- src/lib/domain/__tests__/validation.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit Task 1**
```bash
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`:
```ts
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
buildRenweiCopyOptimizationPrompt,
```
Append these tests:
```ts
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:
```bash
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:
```ts
import { optimizeRenweiCopy } from "../renwei-copy-optimizer";
```
Append this test:
```ts
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:
```bash
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`:
```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`:
```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:
```ts
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`:
```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:
```bash
npm test -- src/lib/llm/__tests__/prompts.test.ts src/lib/workflow/__tests__/llm-integration.test.ts
```
Expected: PASS.
- [ ] **Step 9: Commit Task 2**
```bash
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`:
```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:
```bash
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`:
```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:
```bash
npm test -- src/app/api/__tests__/copy-renwei.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit Task 3**
```bash
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`:
```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:
```bash
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`:
```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:
```ts
import { RenweiCopyOptimizerPanel } from "../components/renwei-copy-optimizer-panel";
```
Add state inside `Home`:
```ts
const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");
```
Replace the single `<div className="workflow-grid">...</div>` block with:
```tsx
<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`:
```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:
```css
.copy-optimizer-grid {
grid-template-columns: 1fr;
}
```
- [ ] **Step 6: Run E2E test to verify GREEN**
Run:
```bash
npx playwright test tests/e2e/renwei-copy.spec.ts
```
Expected: PASS.
- [ ] **Step 7: Commit Task 4**
```bash
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:
```bash
npm run lint
```
Expected: PASS with no ESLint errors.
- [ ] **Step 2: Run unit and API tests**
Run:
```bash
npm test
```
Expected: PASS.
- [ ] **Step 3: Run production build**
Run:
```bash
npm run build
```
Expected: PASS and Next.js build completes.
- [ ] **Step 4: Run targeted E2E**
Run:
```bash
npx playwright test tests/e2e/renwei-copy.spec.ts
```
Expected: PASS.
- [ ] **Step 5: Check public-repo safety before any push**
Run:
```bash
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:
```bash
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.