From 9dd2ad72ef778304e64561492325d3c41383f667 Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Thu, 25 Jun 2026 15:19:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=99=AE=E9=80=9A=E6=96=87?= =?UTF-8?q?=E6=A1=88=E4=BC=98=E5=8C=96=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-25-renwei-copy-optimization.md | 1239 +++++++++++++++++ 1 file changed, 1239 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-renwei-copy-optimization.md diff --git a/docs/superpowers/plans/2026-06-25-renwei-copy-optimization.md b/docs/superpowers/plans/2026-06-25-renwei-copy-optimization.md new file mode 100644 index 0000000..338ee09 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-renwei-copy-optimization.md @@ -0,0 +1,1239 @@ +# 普通文案人味儿优化 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; + +const copyChangeNoteSchema = z.object({ + original: requiredLlmStringSchema, + revised: requiredLlmStringSchema, + reason: requiredLlmStringSchema, + confidence: z.enum(["confident", "uncertain"]), + revertible: z.boolean(), +}) satisfies z.ZodType; + +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; + +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; +``` + +- [ ] **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; + 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 { + 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( + "../../../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 = { "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("light"); + const [userInstructions, setUserInstructions] = useState(""); + const [result, setResult] = useState(null); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function submit(event: FormEvent) { + 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 ( +
+
+
+ 普通文案优化 + +
+