Forráskód Böngészése

refactor: remove numeric prefixes from project file names (00-, 01-, 03-, 09-)

nonvioce 6 hónapja
szülő
commit
0952397a33

+ 10 - 10
CLAUDE.md

@@ -29,7 +29,7 @@ yarn electron:build   # Electron 打包
 4. **世界观描述** — 自由文本描述世界背景
 5. **文风设定** — 文风预设 + 7 种风格模块选择 + 强度/段落模式/章节标题开关
 
-初始化完成后自动写入标准文档集:`00-project-overview.md`、`01-theme-and-proposition.md`、`03-cast-bible.md`、`09-style-guide.md`、`iron-rules.md`、`style-anchor.md`,以及 `world_state/` 和 `event-graph/` 初始文件。
+初始化完成后自动写入标准文档集:`project-overview.md`、`theme-and-proposition.md`、`cast-bible.md`、`writing_style.md`、`iron-rules.md`、`writing_style.md(风格锚定部分)`,以及 `world_state/` 和 `event-graph/` 初始文件。
 
 ### Phase 1 — 世界构建 (WorldEditor + EventGraphEditor)
 
@@ -49,7 +49,7 @@ yarn electron:build   # Electron 打包
 | 1 | Step1PlotSelection | AI 生成 5 个剧情选项,或手动输入 |
 | 2 | Step2EmbedHooks | AI 生成叙事钩子 + 手动添加 |
 | 3 | Step3ResolveHooks | 选择要回收的历史钩子 |
-| 4 | Step4ContentEditor | 流式 AI 生成 + 编辑 + 润色 + 去AI味(三步修订:去模板化、去专业腔、恢复真实语感)。自动注入 `writing_style.md` 和 `09-style-guide.md` |
+| 4 | Step4ContentEditor | 流式 AI 生成 + 编辑 + 润色 + 去AI味(三步修订:去模板化、去专业腔、恢复真实语感)。自动注入 `writing_style.md` 和 `writing_style.md` |
 | 5 | Step5ReviewSubmit | 审查 AI 更新的世界状态文件 + 章节标题确认 + 保存 |
 | 6 | Step6ContinuityCheck | 8 项连续性审计(设定漂移/人物失真/关系断档/伏笔遗失/支线冷落/情绪债务/AI模式化/段落漂移) |
 | 7 | Step7ReaderFeedback | 模拟读者反馈评分(每 5 章自动触发,可覆盖) |
@@ -63,7 +63,7 @@ yarn electron:build   # Electron 打包
 
 **反漂移系统:**
 - **iron-rules.md** — 项目铁律(不可违背的设定),在审计/连续性检查中注入
-- **style-anchor.md** — 风格锚定摘要,注入里程碑审计
+- **writing_style.md(风格锚定部分)** — 风格锚定摘要,注入里程碑审计
 
 ### Phase 3 — 维护面板 (MaintenancePanel)
 
@@ -162,7 +162,7 @@ src/app/
         ├── init/route.ts              # 项目初始化(写入标准文档)
         ├── select-folder/route.ts     # Electron 文件夹选择器
         ├── iron-rules/route.ts        # iron-rules.md 读写
-        ├── style-anchor/route.ts      # style-anchor.md 读写
+        ├── style-anchor/route.ts      # writing_style.md(风格锚定部分) 读写
         ├── summarize/route.ts         # 滚动摘要生成
         ├── audit/route.ts             # 里程碑审计(每20章)
         ├── reader-feedback/route.ts   # 模拟读者反馈(每5章)
@@ -184,12 +184,12 @@ electron/
 ```
 {项目文件夹}/
 ├── novel_project.json              # 项目配置
-├── 00-project-overview.md          # 项目总览
-├── 01-theme-and-proposition.md     # 主题命题
-├── 03-cast-bible.md                # 人物圣经
-├── 09-style-guide.md               # 风格指南(7种模块)
+├── project-overview.md          # 项目总览
+├── theme-and-proposition.md     # 主题命题
+├── cast-bible.md                # 人物圣经
+├── writing_style.md               # 风格指南(7种模块)
 ├── iron-rules.md                   # 项目铁律(反漂移)
-├── style-anchor.md                 # 风格锚定摘要(反漂移)
+├── writing_style.md(风格锚定部分)                 # 风格锚定摘要(反漂移)
 ├── world_state/                    # 世界状态文件(7个标准文件)
 │   ├── 02-core-world-rules.md
 │   ├── 04-world-physics.md
@@ -214,7 +214,7 @@ electron/
 
 ## 内置风格模块(7种)
 
-每个模块包含核心技法 Prompt,在 InitWizard 选择后写入 09-style-guide.md,Step4 生成时自动注入:
+每个模块包含核心技法 Prompt,在 InitWizard 选择后写入 writing_style.md,Step4 生成时自动注入:
 
 | 模块 | 核心原则 |
 |------|---------|

+ 3 - 3
src/app/features/novel-writer/components/InitWizard.tsx

@@ -829,7 +829,7 @@ ${stylePrompt}
     : `# 文风设定\n\n${styleModuleContent}`;
 
   const files: Record<string, string> = {
-    '00-project-overview.md': `# 项目总览
+    'project-overview.md': `# 项目总览
 
 ## 当前阶段
 创作中
@@ -843,7 +843,7 @@ ${paragraphMode}
 ## 风格强度
 ${styleIntensity}/10
 `,
-    '01-theme-and-proposition.md': `# 主题命题
+    'theme-and-proposition.md': `# 主题命题
 
 ## 核心冲突
 ${data.primaryConflict || '(待填写)'}
@@ -854,7 +854,7 @@ ${data.endingDirection || '(待填写)'}
 ## 通过什么人物选择来戏剧化主题
 (待填写)
 `,
-    '03-cast-bible.md': `# 人物圣经
+    'cast-bible.md': `# 人物圣经
 
 ## 主角
 ### 身份与背景

+ 3 - 3
src/app/features/novel-writer/components/pipeline/Step0ControlCard.tsx

@@ -62,9 +62,9 @@ export default function Step0ControlCard({
         'character_relations.md',
         'world_notes.md',
         'world_state/lore/worldbuilding.md',
-        '00-project-overview.md',
-        '01-theme-and-proposition.md',
-        '03-cast-bible.md',
+        'project-overview.md',
+        'theme-and-proposition.md',
+        'cast-bible.md',
       ];
       const contextParts: string[] = [];
 

+ 855 - 855
src/app/features/novel-writer/components/pipeline/Step4ContentEditor.tsx

@@ -1,856 +1,856 @@
-'use client';
-
-import { useState, useCallback, useRef, useEffect } from 'react';
-import { RefreshCw, ChevronRight, ChevronLeft, Loader2, AlertCircle, CheckCircle, Sparkles, PlusSquare, X, Check, Shield } from 'lucide-react';
-import { WritingConstraint, AntiHomogenizationStrategy, EventNode, WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
-import { buildAuthenticityPassPrompt } from '@/app/lib/chapter-prompts';
-
-interface ApiSettings {
-  apiUrl: string;
-  apiKey: string;
-  model: string;
-}
-
-interface Step4ContentEditorProps {
-  projectPath: string;
-  selectedPlot: string;
-  embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
-  manualHooks: string[];
-  resolveHookIds: string[];
-  savedChapterContent: string;
-  apiSettings: ApiSettings | null;
-  writingConstraints: WritingConstraint[];
-  antiHomogenizationStrategies: AntiHomogenizationStrategy[];
-  wordCountMin: number;
-  wordCountMax: number;
-  recentChaptersCount: number;
-  ironRules?: string;
-  onNext: (content: string) => void;
-  onBack: () => void;
-}
-
-const ANTI_HOMOGENIZATION_LABELS: Record<AntiHomogenizationStrategy, string> = {
-  perspective_rotation: '叙事视角轮换',
-  sensory_enhancement: '场景感官强化',
-  authentic_detail: '真实细节注入',
-  rhythm_contrast: '节奏对比',
-  dialogue_personalization: '对话个性化',
-  unexpected_element: '意外元素',
-};
-
-function countChineseWords(text: string): number {
-  const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
-  const englishWords = (text.match(/\b[a-zA-Z]+\b/g) || []).length;
-  return chineseChars + englishWords;
-}
-
-function buildLayeredContext(
-  worldStateFiles: WorldStateFile[],
-  loreFiles: WorldLoreFile[],
-  chapterEvents: EventNode[],
-  ironRules: string,
-  rollingSummaries: string[]
-): string {
-  const parts: string[] = [];
-
-  // Layer 1: Always inject (iron-rules, current chapter events)
-  if (ironRules) parts.push(`## \u9879\u76ee\u94c1\u5f8b\n${ironRules}`);
-  if (chapterEvents && chapterEvents.length > 0) {
-    parts.push(`## \u672c\u7ae0\u4e8b\u4ef6\n${chapterEvents.map(e => `- ${e.title}: ${e.description} [${e.timeLabel}, ${e.location}]`).join('\n')}`);
-  }
-
-  // Layer 2: Rolling summaries (last 2 arcs)
-  if (rollingSummaries.length > 0) {
-    parts.push(`## \u8fd1\u671f\u60c5\u8282\u6458\u8981\n${rollingSummaries.slice(-2).join('\n\n')}`);
-  }
-
-  // Layer 3: Full world state files (truncated per file)
-  const allFiles = [...worldStateFiles, ...loreFiles];
-  for (const file of allFiles) {
-    if (!file.enabled || !file.content.trim()) continue;
-    if (file.fileName === 'narrative_hooks.json' || file.fileName === 'writing_style.md') continue;
-    parts.push(`## ${file.displayName || file.fileName}\n${file.content.slice(0, 2000)}`);
-  }
-
-  return parts.join('\n\n---\n\n');
-}
-
-export default function Step4ContentEditor({
-  projectPath,
-  selectedPlot,
-  embedHooks,
-  manualHooks,
-  resolveHookIds,
-  savedChapterContent,
-  apiSettings,
-  writingConstraints,
-  antiHomogenizationStrategies,
-  wordCountMin,
-  wordCountMax,
-  recentChaptersCount,
-  ironRules,
-  onNext,
-  onBack,
-}: Step4ContentEditorProps) {
-  const [content, setContent] = useState(savedChapterContent);
-  const [isGenerating, setIsGenerating] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-  const [hasGenerated, setHasGenerated] = useState(!!savedChapterContent);
-  const [mainTokenUsage, setMainTokenUsage] = useState<string | null>(null);
-  const textareaRef = useRef<HTMLTextAreaElement>(null);
-  const abortControllerRef = useRef<AbortController | null>(null);
-
-  const [selectionStart, setSelectionStart] = useState(0);
-  const [selectionEnd, setSelectionEnd] = useState(0);
-  const [isPolishing, setIsPolishing] = useState(false);
-  const [polishResult, setPolishResult] = useState<{ original: string; polished: string } | null>(null);
-  const [polishError, setPolishError] = useState<string | null>(null);
-  const [polishTokenUsage, setPolishTokenUsage] = useState<string | null>(null);
-
-  // 真实性修订(去 AI 味)
-  const [isDeAI, setIsDeAI] = useState(false);
-  const [deAIError, setDeAIError] = useState<string | null>(null);
-
-  const [showParagraphPanel, setShowParagraphPanel] = useState(false);
-  const [paragraphTopic, setParagraphTopic] = useState('');
-  const [paragraphRequirements, setParagraphRequirements] = useState('');
-  const [isGeneratingParagraph, setIsGeneratingParagraph] = useState(false);
-  const [paragraphResult, setParagraphResult] = useState<string | null>(null);
-  const [paragraphError, setParagraphError] = useState<string | null>(null);
-  const [paragraphTokenUsage, setParagraphTokenUsage] = useState<string | null>(null);
-  const [cursorPosition, setCursorPosition] = useState(0);
-  const [rollingSummaries, setRollingSummaries] = useState<string[]>([]);
-
-  const wordCount = countChineseWords(content);
-  const isWordCountInRange = wordCount >= wordCountMin && wordCount <= wordCountMax;
-  const wordCountWarning =
-    content.length > 0 && !isWordCountInRange
-      ? wordCount < wordCountMin
-        ? `字数不足:当前 ${wordCount} 字,目标最少 ${wordCountMin} 字`
-        : `字数超出:当前 ${wordCount} 字,目标最多 ${wordCountMax} 字`
-      : null;
-
-  const buildGenerationPrompt = useCallback(
-    async (worldContext: string): Promise<string> => {
-      const enabledConstraints = writingConstraints
-        .filter((c) => c.enabled)
-        .map((c) => `- ${c.label}`)
-        .join('\n');
-
-      const antiStrategies = antiHomogenizationStrategies
-        .map((s) => `- ${ANTI_HOMOGENIZATION_LABELS[s]}`)
-        .join('\n');
-
-      const embedHooksText = embedHooks.length > 0
-        ? `\n本章需要埋入的叙事钩子:\n${embedHooks.map((h) => `- [${h.type}] ${h.description}(埋入方式:${h.embedMethod})`).join('\n')}`
-        : '';
-
-      const manualHooksText = manualHooks.length > 0
-        ? `\n手动添加的叙事钩子:\n${manualHooks.map((h) => `- ${h}`).join('\n')}`
-        : '';
-
-      const resolveHooksText = resolveHookIds.length > 0
-        ? `\n本章需要解决的历史钩子 ID:\n${resolveHookIds.map((id) => `- ${id}`).join('\n')}`
-        : '';
-
-      const hooksText = embedHooksText + manualHooksText + resolveHooksText;
-
-      const constraintsText = enabledConstraints
-        ? `\n写作约束(必须满足):\n${enabledConstraints}`
-        : '';
-
-      const antiText = antiStrategies
-        ? `\n反同质化策略(请在章节中体现):\n${antiStrategies}`
-        : '';
-
-      // Fetch style guide and interview files for injection
-      let contextText = '';
-      try {
-        const styleRes = await fetch(
-          `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
-        );
-        const styleData = await styleRes.json();
-        if (styleData.success && Array.isArray(styleData.files)) {
-          const styleFile = styleData.files.find(
-            (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
-          );
-          if (styleFile?.content) contextText += `\n【文风设定】\n${styleFile.content}\n`;
-        }
-      } catch { /* ignore */ }
-
-      // Fetch interview project files for context
-      const interviewFiles = ['00-project-overview.md', '01-theme-and-proposition.md', '03-cast-bible.md'];
-      for (const fileName of interviewFiles) {
-        try {
-          const res = await fetch('/api/novel/files', {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ action: 'read', projectPath, filePath: fileName }),
-          });
-          if (res.ok) {
-            const data = await res.json();
-            if (data.content) contextText += `\n【${fileName.replace('.md', '')}】\n${data.content}\n`;
-          }
-        } catch { /* file may not exist */ }
-      }
-
-      const antiDriftSection = ironRules ? `## 项目铁律\n${ironRules}` : '';
-
-      return `${worldContext}${contextText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
-
-请根据以下要求创作本章完整内容:
-
-【剧情方向】
-${selectedPlot}
-${hooksText}
-${constraintsText}
-${antiText}
-
-【字数要求】
-本章字数需在 ${wordCountMin} 到 ${wordCountMax} 字之间。
-
-【创作要求】
-1. 严格遵循上方世界状态和文风设定
-2. 完整呈现选定的剧情方向
-3. 自然地埋入所有选定的叙事钩子
-4. 如果有需要解决的历史钩子,在本章中合理地揭示或解决
-5. 满足所有写作约束
-6. 体现反同质化策略,避免风格同质化
-7. 直接输出章节正文,不要包含标题或额外说明
-
-请开始创作:`;
-    },
-    [
-      projectPath,
-      selectedPlot,
-      embedHooks,
-      manualHooks,
-      resolveHookIds,
-      writingConstraints,
-      antiHomogenizationStrategies,
-      wordCountMin,
-      wordCountMax,
-      ironRules,
-    ]
-  );
-
-  const fetchWritingStyle = useCallback(async (): Promise<string> => {
-    try {
-      const res = await fetch(
-        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
-      );
-      const data = await res.json();
-      if (data.success && Array.isArray(data.files)) {
-        const styleFile = data.files.find(
-          (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
-        );
-        return styleFile?.content?.trim() ?? '';
-      }
-    } catch {
-      // 忽略错误
-    }
-    return '';
-  }, [projectPath]);
-
-  const handlePolish = useCallback(async () => {
-    if (!apiSettings) {
-      setPolishError('请先配置 API 设置');
-      return;
-    }
-    const selectedText = content.slice(selectionStart, selectionEnd);
-    if (!selectedText.trim()) return;
-
-    setIsPolishing(true);
-    setPolishError(null);
-    setPolishResult(null);
-    setPolishTokenUsage(null);
-
-    try {
-      const writingStyle = await fetchWritingStyle();
-      const styleSection = writingStyle
-        ? `文风参考:\n${writingStyle}\n\n`
-        : '';
-
-      const prompt = `请对以下文字进行润色,保持原意不变,但提升文学性和可读性。\n\n${styleSection}原文:\n${selectedText}\n\n请直接返回润色后的文字,不要包含任何解释。`;
-
-      const response = await fetch('/api/proxy', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          targetUrl: apiSettings.apiUrl,
-          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
-          body: {
-            model: apiSettings.model,
-            messages: [{ role: 'user', content: prompt }],
-            stream: false,
-          },
-        }),
-      });
-
-      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
-      const data = await response.json();
-      const polished =
-        data?.choices?.[0]?.message?.content?.trim() ??
-        data?.choices?.[0]?.text?.trim() ??
-        '';
-
-      if (!polished) throw new Error('AI 返回内容为空');
-      setPolishResult({ original: selectedText, polished });
-      if (data?.usage?.total_tokens) {
-        setPolishTokenUsage(
-          `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
-        );
-      }
-    } catch (err) {
-      setPolishError(err instanceof Error ? err.message : '润色失败,请重试');
-    } finally {
-      setIsPolishing(false);
-    }
-  }, [apiSettings, content, selectionStart, selectionEnd, fetchWritingStyle]);
-
-  const acceptPolish = useCallback(() => {
-    if (!polishResult) return;
-    const newContent =
-      content.slice(0, selectionStart) +
-      polishResult.polished +
-      content.slice(selectionEnd);
-    setContent(newContent);
-    setPolishResult(null);
-    setSelectionStart(0);
-    setSelectionEnd(0);
-  }, [polishResult, content, selectionStart, selectionEnd]);
-
-  const rejectPolish = useCallback(() => {
-    setPolishResult(null);
-    setPolishError(null);
-  }, []);
-
-  // 真实性修订(去 AI 味)- 对全文进行三步修订
-  const handleDeAI = useCallback(async () => {
-    if (!content.trim() || !apiSettings) return;
-    setIsDeAI(true);
-    setDeAIError(null);
-    try {
-      const prompt = buildAuthenticityPassPrompt(content, '');
-      const requestBody = {
-        model: apiSettings.model,
-        messages: [
-          { role: 'system' as const, content: '你是小说文字编辑,擅长去除AI痕迹、恢复真实语感。用中文输出。' },
-          { role: 'user' as const, content: prompt },
-        ],
-        temperature: 0.4,
-        stream: false,
-      };
-      const headers: Record<string, string> = { 'Content-Type': 'application/json' };
-      if (apiSettings.apiKey) headers['Authorization'] = `Bearer ${apiSettings.apiKey}`;
-      const res = await fetch('/api/proxy', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          targetUrl: apiSettings.apiUrl,
-          headers,
-          body: requestBody,
-          isOllama: apiSettings.apiUrl.includes('11434'),
-        }),
-      });
-      if (!res.ok) throw new Error(`API error: ${res.status}`);
-      const data = await res.json();
-      const deAIContent = data.choices?.[0]?.message?.content;
-      if (deAIContent) { setContent(deAIContent); }
-    } catch (err) {
-      setDeAIError(err instanceof Error ? err.message : '修订失败');
-    } finally {
-      setIsDeAI(false);
-    }
-  }, [content, apiSettings]);
-
-  const handleGenerateParagraph = useCallback(async () => {
-    if (!apiSettings) {
-      setParagraphError('请先配置 API 设置');
-      return;
-    }
-    if (!paragraphTopic.trim()) {
-      setParagraphError('请输入段落主题');
-      return;
-    }
-
-    setIsGeneratingParagraph(true);
-    setParagraphError(null);
-    setParagraphResult(null);
-    setParagraphTokenUsage(null);
-
-    try {
-      const filesRes = await fetch(
-        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
-      );
-      const filesData = await filesRes.json();
-
-      const worldStateFiles: WorldStateFile[] = [];
-      const loreFiles: WorldLoreFile[] = [];
-      if (filesData.success && Array.isArray(filesData.files)) {
-        for (const file of filesData.files) {
-          if (!file.enabled || !file.content?.trim()) continue;
-          if (file.isLore) loreFiles.push(file as WorldLoreFile);
-          else worldStateFiles.push(file as WorldStateFile);
-        }
-      }
-
-      let chapterEvents: EventNode[] = [];
-      try {
-        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
-        const evtData = await evtRes.json();
-        if (evtData.success && Array.isArray(evtData.events)) {
-          chapterEvents = evtData.events as EventNode[];
-        }
-      } catch { /* ignore */ }
-
-      const worldContext = buildLayeredContext(
-        worldStateFiles,
-        loreFiles,
-        chapterEvents,
-        ironRules || '',
-        rollingSummaries
-      );
-
-      const requirementsSection = paragraphRequirements.trim()
-        ? `要求:${paragraphRequirements.trim()}\n`
-        : '';
-
-      const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
-
-      const response = await fetch('/api/proxy', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          targetUrl: apiSettings.apiUrl,
-          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
-          body: {
-            model: apiSettings.model,
-            messages: [{ role: 'user', content: prompt }],
-            stream: false,
-          },
-        }),
-      });
-
-      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
-      const data = await response.json();
-      const generated =
-        data?.choices?.[0]?.message?.content?.trim() ??
-        data?.choices?.[0]?.text?.trim() ??
-        '';
-
-      if (!generated) throw new Error('AI 返回内容为空');
-      setParagraphResult(generated);
-      if (data?.usage?.total_tokens) {
-        setParagraphTokenUsage(
-          `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
-        );
-      }
-    } catch (err) {
-      setParagraphError(err instanceof Error ? err.message : '段落生成失败,请重试');
-    } finally {
-      setIsGeneratingParagraph(false);
-    }
-  }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements, ironRules, rollingSummaries]);
-
-  const acceptParagraph = useCallback(() => {
-    if (!paragraphResult) return;
-    const insertAt = cursorPosition;
-    const newContent =
-      content.slice(0, insertAt) +
-      (content[insertAt - 1] && content[insertAt - 1] !== '\n' ? '\n' : '') +
-      paragraphResult +
-      (content[insertAt] && content[insertAt] !== '\n' ? '\n' : '') +
-      content.slice(insertAt);
-    setContent(newContent);
-    setParagraphResult(null);
-    setShowParagraphPanel(false);
-    setParagraphTopic('');
-    setParagraphRequirements('');
-  }, [paragraphResult, content, cursorPosition]);
-
-  const rejectParagraph = useCallback(() => {
-    setParagraphResult(null);
-    setParagraphError(null);
-  }, []);
-
-  const generateContent = useCallback(async () => {
-    if (!apiSettings) {
-      setError('请先配置 API 设置');
-      return;
-    }
-
-    if (abortControllerRef.current) {
-      abortControllerRef.current.abort();
-    }
-    abortControllerRef.current = new AbortController();
-
-    setIsGenerating(true);
-    setError(null);
-    setContent('');
-    setMainTokenUsage(null);
-
-    try {
-      const filesResponse = await fetch(
-        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
-      );
-      const filesData = await filesResponse.json();
-
-      const worldStateFiles: WorldStateFile[] = [];
-      const loreFiles: WorldLoreFile[] = [];
-      if (filesData.success && Array.isArray(filesData.files)) {
-        for (const file of filesData.files) {
-          if (!file.enabled || !file.content?.trim()) continue;
-          if (file.isLore) loreFiles.push(file as WorldLoreFile);
-          else worldStateFiles.push(file as WorldStateFile);
-        }
-      }
-
-      let chapterEvents: EventNode[] = [];
-      try {
-        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
-        const evtData = await evtRes.json();
-        if (evtData.success && Array.isArray(evtData.events)) {
-          chapterEvents = evtData.events as EventNode[];
-        }
-      } catch { /* ignore */ }
-
-      const worldContext = buildLayeredContext(
-        worldStateFiles,
-        loreFiles,
-        chapterEvents,
-        ironRules || '',
-        rollingSummaries
-      );
-
-      const prompt = await buildGenerationPrompt(worldContext);
-
-      const response = await fetch('/api/stream-proxy', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          targetUrl: apiSettings.apiUrl,
-          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
-          body: {
-            model: apiSettings.model,
-            messages: [{ role: 'user', content: prompt }],
-            stream: true,
-            stream_options: { include_usage: true },
-            max_tokens: 4000,
-          },
-        }),
-        signal: abortControllerRef.current.signal,
-      });
-
-      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
-      if (!response.body) throw new Error('响应体为空');
-
-      const reader = response.body.getReader();
-      const decoder = new TextDecoder();
-      let accumulated = '';
-
-      while (true) {
-        const { done, value } = await reader.read();
-        if (done) break;
-
-        const chunk = decoder.decode(value, { stream: true });
-        const lines = chunk.split('\n');
-
-        for (const line of lines) {
-          if (!line.startsWith('data: ')) continue;
-          const data = line.slice(6).trim();
-          if (data === '[DONE]') break;
-
-          try {
-            const parsed = JSON.parse(data);
-            if (parsed?.usage?.total_tokens) {
-              setMainTokenUsage(
-                `Token 消耗:输入 ${parsed.usage.prompt_tokens ?? 0} · 输出 ${parsed.usage.completion_tokens ?? 0} · 总计 ${parsed.usage.total_tokens}`
-              );
-            }
-            const delta = parsed.choices?.[0]?.delta?.content;
-            if (delta) {
-              accumulated += delta;
-              setContent(accumulated);
-              if (textareaRef.current) {
-                textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
-              }
-            }
-          } catch {
-            // 忽略解析错误
-          }
-        }
-      }
-
-      setHasGenerated(true);
-    } catch (err) {
-      if (err instanceof Error && err.name === 'AbortError') return;
-      setError(err instanceof Error ? err.message : '生成章节内容失败,请重试');
-    } finally {
-      setIsGenerating(false);
-    }
-  }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, rollingSummaries]);
-
-  useEffect(() => {
-    return () => {
-      abortControllerRef.current?.abort();
-    };
-  }, []);
-
-  useEffect(() => {
-    fetch(`/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&filePath=world_state/rolling-summaries`)
-      .then(r => r.json())
-      .then(data => {
-        if (data.success && data.files) {
-          setRollingSummaries(data.files.map((f: {content: string}) => f.content));
-        }
-      })
-      .catch(() => {});
-  }, [projectPath]);
-
-  const handleNext = () => {
-    if (!content.trim()) return;
-    onNext(content);
-  };
-
-  return (
-    <div className="flex flex-col h-full">
-      {wordCountWarning && (
-        <div className="flex items-center gap-2 p-3 mb-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
-          <AlertCircle className="h-4 w-4 flex-shrink-0" />
-          {wordCountWarning}
-        </div>
-      )}
-
-      <div className="flex items-center justify-between mb-4">
-        <div>
-          <h2 className="text-xl font-semibold text-gray-900">生成章节内容</h2>
-          <p className="text-sm text-gray-500 mt-1">
-            AI 正在根据剧情方向和钩子生成章节,您可以直接编辑修改
-          </p>
-        </div>
-        <div className="flex items-center gap-2">
-          <button
-            onClick={handleDeAI}
-            disabled={isDeAI || !apiSettings || !content.trim() || isGenerating}
-            title="对全文执行三步去AI味修订(去模板化→去专业腔→恢复真实语感)"
-            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-          >
-            {isDeAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
-            去AI味
-          </button>
-          <button
-            onClick={handlePolish}
-            disabled={isPolishing || !apiSettings || selectionStart === selectionEnd || isGenerating}
-            title={selectionStart === selectionEnd ? '请先在编辑器中选中文字' : '对选中文字进行润色'}
-            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-          >
-            {isPolishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
-            文本润色
-          </button>
-
-          <button
-            onClick={() => {
-              setShowParagraphPanel(true);
-              setParagraphResult(null);
-              setParagraphError(null);
-            }}
-            disabled={isGenerating || !apiSettings}
-            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-          >
-            <PlusSquare className="h-4 w-4" />
-            段落生成
-          </button>
-
-          <button
-            onClick={generateContent}
-            disabled={isGenerating || !apiSettings}
-            className="flex items-center gap-1.5 px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-          >
-            {isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
-            {hasGenerated ? '重新生成' : '生成内容'}
-          </button>
-        </div>
-      </div>
-
-      {deAIError && (
-        <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
-          <AlertCircle className="h-4 w-4 flex-shrink-0" /> 去AI味修订失败: {deAIError}
-          <button onClick={() => setDeAIError(null)} className="ml-auto text-red-500 hover:text-red-700">
-            <X className="h-4 w-4" /></button></div>
-      )}
-
-      {polishError && (
-        <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
-          <AlertCircle className="h-4 w-4 flex-shrink-0" />
-          {polishError}
-          <button onClick={rejectPolish} className="ml-auto text-red-500 hover:text-red-700">
-            <X className="h-4 w-4" />
-          </button>
-        </div>
-      )}
-
-      {polishTokenUsage && (
-        <p className="text-[11px] text-gray-500 mb-3">{polishTokenUsage}</p>
-      )}
-
-      {polishResult && (
-        <div className="mb-4 border border-amber-200 rounded-xl overflow-hidden bg-amber-50">
-          <div className="flex items-center justify-between px-4 py-2 bg-amber-100 border-b border-amber-200">
-            <span className="text-sm font-medium text-amber-800">文本润色结果对比</span>
-            <div className="flex items-center gap-2">
-              <button
-                onClick={acceptPolish}
-                className="flex items-center gap-1 px-3 py-1 text-xs bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
-              >
-                <Check className="h-3 w-3" />
-                接受
-              </button>
-              <button
-                onClick={rejectPolish}
-                className="flex items-center gap-1 px-3 py-1 text-xs bg-gray-500 text-white rounded-md hover:bg-gray-600 transition-colors"
-              >
-                <X className="h-3 w-3" />
-                拒绝
-              </button>
-            </div>
-          </div>
-          <div className="grid grid-cols-2 divide-x divide-amber-200">
-            <div className="p-4">
-              <p className="text-xs text-amber-600 mb-1">原文</p>
-              <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.original}</p>
-            </div>
-            <div className="p-4">
-              <p className="text-xs text-green-600 mb-1">润色后</p>
-              <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.polished}</p>
-            </div>
-          </div>
-        </div>
-      )}
-
-      {showParagraphPanel && (
-        <div className="mb-4 border border-emerald-200 rounded-xl overflow-hidden bg-emerald-50">
-          <div className="flex items-center justify-between px-4 py-2 bg-emerald-100 border-b border-emerald-200">
-            <span className="text-sm font-medium text-emerald-800">段落生成</span>
-            <button onClick={() => setShowParagraphPanel(false)} className="text-emerald-600 hover:text-emerald-800">
-              <X className="h-4 w-4" />
-            </button>
-          </div>
-          <div className="p-4 space-y-3">
-            <input
-              type="text"
-              value={paragraphTopic}
-              onChange={(e) => setParagraphTopic(e.target.value)}
-              placeholder="输入段落主题..."
-              className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500"
-            />
-            <textarea
-              value={paragraphRequirements}
-              onChange={(e) => setParagraphRequirements(e.target.value)}
-              placeholder="可选:输入额外要求..."
-              className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none h-20"
-            />
-            {paragraphError && (
-              <p className="text-xs text-red-600">{paragraphError}</p>
-            )}
-            {paragraphTokenUsage && (
-              <p className="text-[11px] text-gray-500">{paragraphTokenUsage}</p>
-            )}
-            <div className="flex gap-2">
-              <button
-                onClick={handleGenerateParagraph}
-                disabled={isGeneratingParagraph || !apiSettings}
-                className="flex items-center gap-1.5 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
-              >
-                {isGeneratingParagraph ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
-                生成段落
-              </button>
-              {paragraphResult && (
-                <button
-                  onClick={acceptParagraph}
-                  className="flex items-center gap-1 px-3 py-2 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
-                >
-                  <Check className="h-3 w-3" />
-                  插入光标位置
-                </button>
-              )}
-              {paragraphResult && (
-                <button
-                  onClick={rejectParagraph}
-                  className="flex items-center gap-1 px-3 py-2 text-xs bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
-                >
-                  <X className="h-3 w-3" />
-                  取消
-                </button>
-              )}
-            </div>
-            {paragraphResult && (
-              <div className="p-3 bg-white border border-emerald-200 rounded-lg">
-                <p className="text-xs text-emerald-600 mb-1">生成的段落</p>
-                <p className="text-sm text-gray-700 whitespace-pre-wrap">{paragraphResult}</p>
-              </div>
-            )}
-          </div>
-        </div>
-      )}
-
-      <div className="flex-1 flex flex-col min-h-0 mb-4">
-        <div className="flex items-center justify-between mb-2">
-          <span className="text-sm font-medium text-gray-700">
-            章节内容 {wordCount} / {wordCountMin}-{wordCountMax} 字
-          </span>
-        </div>
-        <textarea
-          ref={textareaRef}
-          value={content}
-          onChange={(e) => setContent(e.target.value)}
-          onSelect={(e) => {
-            const target = e.target as HTMLTextAreaElement;
-            setSelectionStart(target.selectionStart);
-            setSelectionEnd(target.selectionEnd);
-          }}
-          onClick={(e) => {
-            const target = e.target as HTMLTextAreaElement;
-            setCursorPosition(target.selectionStart);
-          }}
-          onKeyUp={(e) => {
-            const target = e.target as HTMLTextAreaElement;
-            setCursorPosition(target.selectionStart);
-          }}
-          placeholder="点击「生成内容」按钮让 AI 创作本章..."
-          className="flex-1 w-full px-4 py-3 text-sm border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500 font-serif leading-relaxed"
-          disabled={isGenerating}
-        />
-      </div>
-
-      {mainTokenUsage && (
-        <p className="text-[11px] text-gray-500 mb-3">{mainTokenUsage}</p>
-      )}
-
-      {error && (
-        <div className="flex items-center gap-2 p-3 mb-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
-          <AlertCircle className="h-4 w-4 flex-shrink-0" />
-          {error}
-        </div>
-      )}
-
-      <div className="mt-auto pt-4 border-t border-gray-200 flex items-center justify-between">
-        <button
-          onClick={onBack}
-          className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
-        >
-          <ChevronLeft className="h-4 w-4" />
-          上一步:解决钩子
-        </button>
-        <button
-          onClick={handleNext}
-          disabled={!content.trim() || isGenerating}
-          className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-        >
-          下一步:审核提交
-          <ChevronRight className="h-4 w-4" />
-        </button>
-      </div>
-    </div>
-  );
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { RefreshCw, ChevronRight, ChevronLeft, Loader2, AlertCircle, CheckCircle, Sparkles, PlusSquare, X, Check, Shield } from 'lucide-react';
+import { WritingConstraint, AntiHomogenizationStrategy, EventNode, WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
+import { buildAuthenticityPassPrompt } from '@/app/lib/chapter-prompts';
+
+interface ApiSettings {
+  apiUrl: string;
+  apiKey: string;
+  model: string;
+}
+
+interface Step4ContentEditorProps {
+  projectPath: string;
+  selectedPlot: string;
+  embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
+  manualHooks: string[];
+  resolveHookIds: string[];
+  savedChapterContent: string;
+  apiSettings: ApiSettings | null;
+  writingConstraints: WritingConstraint[];
+  antiHomogenizationStrategies: AntiHomogenizationStrategy[];
+  wordCountMin: number;
+  wordCountMax: number;
+  recentChaptersCount: number;
+  ironRules?: string;
+  onNext: (content: string) => void;
+  onBack: () => void;
+}
+
+const ANTI_HOMOGENIZATION_LABELS: Record<AntiHomogenizationStrategy, string> = {
+  perspective_rotation: '叙事视角轮换',
+  sensory_enhancement: '场景感官强化',
+  authentic_detail: '真实细节注入',
+  rhythm_contrast: '节奏对比',
+  dialogue_personalization: '对话个性化',
+  unexpected_element: '意外元素',
+};
+
+function countChineseWords(text: string): number {
+  const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
+  const englishWords = (text.match(/\b[a-zA-Z]+\b/g) || []).length;
+  return chineseChars + englishWords;
+}
+
+function buildLayeredContext(
+  worldStateFiles: WorldStateFile[],
+  loreFiles: WorldLoreFile[],
+  chapterEvents: EventNode[],
+  ironRules: string,
+  rollingSummaries: string[]
+): string {
+  const parts: string[] = [];
+
+  // Layer 1: Always inject (iron-rules, current chapter events)
+  if (ironRules) parts.push(`## \u9879\u76ee\u94c1\u5f8b\n${ironRules}`);
+  if (chapterEvents && chapterEvents.length > 0) {
+    parts.push(`## \u672c\u7ae0\u4e8b\u4ef6\n${chapterEvents.map(e => `- ${e.title}: ${e.description} [${e.timeLabel}, ${e.location}]`).join('\n')}`);
+  }
+
+  // Layer 2: Rolling summaries (last 2 arcs)
+  if (rollingSummaries.length > 0) {
+    parts.push(`## \u8fd1\u671f\u60c5\u8282\u6458\u8981\n${rollingSummaries.slice(-2).join('\n\n')}`);
+  }
+
+  // Layer 3: Full world state files (truncated per file)
+  const allFiles = [...worldStateFiles, ...loreFiles];
+  for (const file of allFiles) {
+    if (!file.enabled || !file.content.trim()) continue;
+    if (file.fileName === 'narrative_hooks.json' || file.fileName === 'writing_style.md') continue;
+    parts.push(`## ${file.displayName || file.fileName}\n${file.content.slice(0, 2000)}`);
+  }
+
+  return parts.join('\n\n---\n\n');
+}
+
+export default function Step4ContentEditor({
+  projectPath,
+  selectedPlot,
+  embedHooks,
+  manualHooks,
+  resolveHookIds,
+  savedChapterContent,
+  apiSettings,
+  writingConstraints,
+  antiHomogenizationStrategies,
+  wordCountMin,
+  wordCountMax,
+  recentChaptersCount,
+  ironRules,
+  onNext,
+  onBack,
+}: Step4ContentEditorProps) {
+  const [content, setContent] = useState(savedChapterContent);
+  const [isGenerating, setIsGenerating] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [hasGenerated, setHasGenerated] = useState(!!savedChapterContent);
+  const [mainTokenUsage, setMainTokenUsage] = useState<string | null>(null);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const abortControllerRef = useRef<AbortController | null>(null);
+
+  const [selectionStart, setSelectionStart] = useState(0);
+  const [selectionEnd, setSelectionEnd] = useState(0);
+  const [isPolishing, setIsPolishing] = useState(false);
+  const [polishResult, setPolishResult] = useState<{ original: string; polished: string } | null>(null);
+  const [polishError, setPolishError] = useState<string | null>(null);
+  const [polishTokenUsage, setPolishTokenUsage] = useState<string | null>(null);
+
+  // 真实性修订(去 AI 味)
+  const [isDeAI, setIsDeAI] = useState(false);
+  const [deAIError, setDeAIError] = useState<string | null>(null);
+
+  const [showParagraphPanel, setShowParagraphPanel] = useState(false);
+  const [paragraphTopic, setParagraphTopic] = useState('');
+  const [paragraphRequirements, setParagraphRequirements] = useState('');
+  const [isGeneratingParagraph, setIsGeneratingParagraph] = useState(false);
+  const [paragraphResult, setParagraphResult] = useState<string | null>(null);
+  const [paragraphError, setParagraphError] = useState<string | null>(null);
+  const [paragraphTokenUsage, setParagraphTokenUsage] = useState<string | null>(null);
+  const [cursorPosition, setCursorPosition] = useState(0);
+  const [rollingSummaries, setRollingSummaries] = useState<string[]>([]);
+
+  const wordCount = countChineseWords(content);
+  const isWordCountInRange = wordCount >= wordCountMin && wordCount <= wordCountMax;
+  const wordCountWarning =
+    content.length > 0 && !isWordCountInRange
+      ? wordCount < wordCountMin
+        ? `字数不足:当前 ${wordCount} 字,目标最少 ${wordCountMin} 字`
+        : `字数超出:当前 ${wordCount} 字,目标最多 ${wordCountMax} 字`
+      : null;
+
+  const buildGenerationPrompt = useCallback(
+    async (worldContext: string): Promise<string> => {
+      const enabledConstraints = writingConstraints
+        .filter((c) => c.enabled)
+        .map((c) => `- ${c.label}`)
+        .join('\n');
+
+      const antiStrategies = antiHomogenizationStrategies
+        .map((s) => `- ${ANTI_HOMOGENIZATION_LABELS[s]}`)
+        .join('\n');
+
+      const embedHooksText = embedHooks.length > 0
+        ? `\n本章需要埋入的叙事钩子:\n${embedHooks.map((h) => `- [${h.type}] ${h.description}(埋入方式:${h.embedMethod})`).join('\n')}`
+        : '';
+
+      const manualHooksText = manualHooks.length > 0
+        ? `\n手动添加的叙事钩子:\n${manualHooks.map((h) => `- ${h}`).join('\n')}`
+        : '';
+
+      const resolveHooksText = resolveHookIds.length > 0
+        ? `\n本章需要解决的历史钩子 ID:\n${resolveHookIds.map((id) => `- ${id}`).join('\n')}`
+        : '';
+
+      const hooksText = embedHooksText + manualHooksText + resolveHooksText;
+
+      const constraintsText = enabledConstraints
+        ? `\n写作约束(必须满足):\n${enabledConstraints}`
+        : '';
+
+      const antiText = antiStrategies
+        ? `\n反同质化策略(请在章节中体现):\n${antiStrategies}`
+        : '';
+
+      // Fetch style guide and interview files for injection
+      let contextText = '';
+      try {
+        const styleRes = await fetch(
+          `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
+        );
+        const styleData = await styleRes.json();
+        if (styleData.success && Array.isArray(styleData.files)) {
+          const styleFile = styleData.files.find(
+            (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
+          );
+          if (styleFile?.content) contextText += `\n【文风设定】\n${styleFile.content}\n`;
+        }
+      } catch { /* ignore */ }
+
+      // Fetch interview project files for context
+      const interviewFiles = ['00-project-overview.md', '01-theme-and-proposition.md', '03-cast-bible.md'];
+      for (const fileName of interviewFiles) {
+        try {
+          const res = await fetch('/api/novel/files', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ action: 'read', projectPath, filePath: fileName }),
+          });
+          if (res.ok) {
+            const data = await res.json();
+            if (data.content) contextText += `\n【${fileName.replace('.md', '')}】\n${data.content}\n`;
+          }
+        } catch { /* file may not exist */ }
+      }
+
+      const antiDriftSection = ironRules ? `## 项目铁律\n${ironRules}` : '';
+
+      return `${worldContext}${contextText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
+
+请根据以下要求创作本章完整内容:
+
+【剧情方向】
+${selectedPlot}
+${hooksText}
+${constraintsText}
+${antiText}
+
+【字数要求】
+本章字数需在 ${wordCountMin} 到 ${wordCountMax} 字之间。
+
+【创作要求】
+1. 严格遵循上方世界状态和文风设定
+2. 完整呈现选定的剧情方向
+3. 自然地埋入所有选定的叙事钩子
+4. 如果有需要解决的历史钩子,在本章中合理地揭示或解决
+5. 满足所有写作约束
+6. 体现反同质化策略,避免风格同质化
+7. 直接输出章节正文,不要包含标题或额外说明
+
+请开始创作:`;
+    },
+    [
+      projectPath,
+      selectedPlot,
+      embedHooks,
+      manualHooks,
+      resolveHookIds,
+      writingConstraints,
+      antiHomogenizationStrategies,
+      wordCountMin,
+      wordCountMax,
+      ironRules,
+    ]
+  );
+
+  const fetchWritingStyle = useCallback(async (): Promise<string> => {
+    try {
+      const res = await fetch(
+        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
+      );
+      const data = await res.json();
+      if (data.success && Array.isArray(data.files)) {
+        const styleFile = data.files.find(
+          (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
+        );
+        return styleFile?.content?.trim() ?? '';
+      }
+    } catch {
+      // 忽略错误
+    }
+    return '';
+  }, [projectPath]);
+
+  const handlePolish = useCallback(async () => {
+    if (!apiSettings) {
+      setPolishError('请先配置 API 设置');
+      return;
+    }
+    const selectedText = content.slice(selectionStart, selectionEnd);
+    if (!selectedText.trim()) return;
+
+    setIsPolishing(true);
+    setPolishError(null);
+    setPolishResult(null);
+    setPolishTokenUsage(null);
+
+    try {
+      const writingStyle = await fetchWritingStyle();
+      const styleSection = writingStyle
+        ? `文风参考:\n${writingStyle}\n\n`
+        : '';
+
+      const prompt = `请对以下文字进行润色,保持原意不变,但提升文学性和可读性。\n\n${styleSection}原文:\n${selectedText}\n\n请直接返回润色后的文字,不要包含任何解释。`;
+
+      const response = await fetch('/api/proxy', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          targetUrl: apiSettings.apiUrl,
+          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
+          body: {
+            model: apiSettings.model,
+            messages: [{ role: 'user', content: prompt }],
+            stream: false,
+          },
+        }),
+      });
+
+      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
+      const data = await response.json();
+      const polished =
+        data?.choices?.[0]?.message?.content?.trim() ??
+        data?.choices?.[0]?.text?.trim() ??
+        '';
+
+      if (!polished) throw new Error('AI 返回内容为空');
+      setPolishResult({ original: selectedText, polished });
+      if (data?.usage?.total_tokens) {
+        setPolishTokenUsage(
+          `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
+        );
+      }
+    } catch (err) {
+      setPolishError(err instanceof Error ? err.message : '润色失败,请重试');
+    } finally {
+      setIsPolishing(false);
+    }
+  }, [apiSettings, content, selectionStart, selectionEnd, fetchWritingStyle]);
+
+  const acceptPolish = useCallback(() => {
+    if (!polishResult) return;
+    const newContent =
+      content.slice(0, selectionStart) +
+      polishResult.polished +
+      content.slice(selectionEnd);
+    setContent(newContent);
+    setPolishResult(null);
+    setSelectionStart(0);
+    setSelectionEnd(0);
+  }, [polishResult, content, selectionStart, selectionEnd]);
+
+  const rejectPolish = useCallback(() => {
+    setPolishResult(null);
+    setPolishError(null);
+  }, []);
+
+  // 真实性修订(去 AI 味)- 对全文进行三步修订
+  const handleDeAI = useCallback(async () => {
+    if (!content.trim() || !apiSettings) return;
+    setIsDeAI(true);
+    setDeAIError(null);
+    try {
+      const prompt = buildAuthenticityPassPrompt(content, '');
+      const requestBody = {
+        model: apiSettings.model,
+        messages: [
+          { role: 'system' as const, content: '你是小说文字编辑,擅长去除AI痕迹、恢复真实语感。用中文输出。' },
+          { role: 'user' as const, content: prompt },
+        ],
+        temperature: 0.4,
+        stream: false,
+      };
+      const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+      if (apiSettings.apiKey) headers['Authorization'] = `Bearer ${apiSettings.apiKey}`;
+      const res = await fetch('/api/proxy', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          targetUrl: apiSettings.apiUrl,
+          headers,
+          body: requestBody,
+          isOllama: apiSettings.apiUrl.includes('11434'),
+        }),
+      });
+      if (!res.ok) throw new Error(`API error: ${res.status}`);
+      const data = await res.json();
+      const deAIContent = data.choices?.[0]?.message?.content;
+      if (deAIContent) { setContent(deAIContent); }
+    } catch (err) {
+      setDeAIError(err instanceof Error ? err.message : '修订失败');
+    } finally {
+      setIsDeAI(false);
+    }
+  }, [content, apiSettings]);
+
+  const handleGenerateParagraph = useCallback(async () => {
+    if (!apiSettings) {
+      setParagraphError('请先配置 API 设置');
+      return;
+    }
+    if (!paragraphTopic.trim()) {
+      setParagraphError('请输入段落主题');
+      return;
+    }
+
+    setIsGeneratingParagraph(true);
+    setParagraphError(null);
+    setParagraphResult(null);
+    setParagraphTokenUsage(null);
+
+    try {
+      const filesRes = await fetch(
+        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
+      );
+      const filesData = await filesRes.json();
+
+      const worldStateFiles: WorldStateFile[] = [];
+      const loreFiles: WorldLoreFile[] = [];
+      if (filesData.success && Array.isArray(filesData.files)) {
+        for (const file of filesData.files) {
+          if (!file.enabled || !file.content?.trim()) continue;
+          if (file.isLore) loreFiles.push(file as WorldLoreFile);
+          else worldStateFiles.push(file as WorldStateFile);
+        }
+      }
+
+      let chapterEvents: EventNode[] = [];
+      try {
+        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+        const evtData = await evtRes.json();
+        if (evtData.success && Array.isArray(evtData.events)) {
+          chapterEvents = evtData.events as EventNode[];
+        }
+      } catch { /* ignore */ }
+
+      const worldContext = buildLayeredContext(
+        worldStateFiles,
+        loreFiles,
+        chapterEvents,
+        ironRules || '',
+        rollingSummaries
+      );
+
+      const requirementsSection = paragraphRequirements.trim()
+        ? `要求:${paragraphRequirements.trim()}\n`
+        : '';
+
+      const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
+
+      const response = await fetch('/api/proxy', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          targetUrl: apiSettings.apiUrl,
+          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
+          body: {
+            model: apiSettings.model,
+            messages: [{ role: 'user', content: prompt }],
+            stream: false,
+          },
+        }),
+      });
+
+      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
+      const data = await response.json();
+      const generated =
+        data?.choices?.[0]?.message?.content?.trim() ??
+        data?.choices?.[0]?.text?.trim() ??
+        '';
+
+      if (!generated) throw new Error('AI 返回内容为空');
+      setParagraphResult(generated);
+      if (data?.usage?.total_tokens) {
+        setParagraphTokenUsage(
+          `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
+        );
+      }
+    } catch (err) {
+      setParagraphError(err instanceof Error ? err.message : '段落生成失败,请重试');
+    } finally {
+      setIsGeneratingParagraph(false);
+    }
+  }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements, ironRules, rollingSummaries]);
+
+  const acceptParagraph = useCallback(() => {
+    if (!paragraphResult) return;
+    const insertAt = cursorPosition;
+    const newContent =
+      content.slice(0, insertAt) +
+      (content[insertAt - 1] && content[insertAt - 1] !== '\n' ? '\n' : '') +
+      paragraphResult +
+      (content[insertAt] && content[insertAt] !== '\n' ? '\n' : '') +
+      content.slice(insertAt);
+    setContent(newContent);
+    setParagraphResult(null);
+    setShowParagraphPanel(false);
+    setParagraphTopic('');
+    setParagraphRequirements('');
+  }, [paragraphResult, content, cursorPosition]);
+
+  const rejectParagraph = useCallback(() => {
+    setParagraphResult(null);
+    setParagraphError(null);
+  }, []);
+
+  const generateContent = useCallback(async () => {
+    if (!apiSettings) {
+      setError('请先配置 API 设置');
+      return;
+    }
+
+    if (abortControllerRef.current) {
+      abortControllerRef.current.abort();
+    }
+    abortControllerRef.current = new AbortController();
+
+    setIsGenerating(true);
+    setError(null);
+    setContent('');
+    setMainTokenUsage(null);
+
+    try {
+      const filesResponse = await fetch(
+        `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
+      );
+      const filesData = await filesResponse.json();
+
+      const worldStateFiles: WorldStateFile[] = [];
+      const loreFiles: WorldLoreFile[] = [];
+      if (filesData.success && Array.isArray(filesData.files)) {
+        for (const file of filesData.files) {
+          if (!file.enabled || !file.content?.trim()) continue;
+          if (file.isLore) loreFiles.push(file as WorldLoreFile);
+          else worldStateFiles.push(file as WorldStateFile);
+        }
+      }
+
+      let chapterEvents: EventNode[] = [];
+      try {
+        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+        const evtData = await evtRes.json();
+        if (evtData.success && Array.isArray(evtData.events)) {
+          chapterEvents = evtData.events as EventNode[];
+        }
+      } catch { /* ignore */ }
+
+      const worldContext = buildLayeredContext(
+        worldStateFiles,
+        loreFiles,
+        chapterEvents,
+        ironRules || '',
+        rollingSummaries
+      );
+
+      const prompt = await buildGenerationPrompt(worldContext);
+
+      const response = await fetch('/api/stream-proxy', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          targetUrl: apiSettings.apiUrl,
+          headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
+          body: {
+            model: apiSettings.model,
+            messages: [{ role: 'user', content: prompt }],
+            stream: true,
+            stream_options: { include_usage: true },
+            max_tokens: 4000,
+          },
+        }),
+        signal: abortControllerRef.current.signal,
+      });
+
+      if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
+      if (!response.body) throw new Error('响应体为空');
+
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let accumulated = '';
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        const chunk = decoder.decode(value, { stream: true });
+        const lines = chunk.split('\n');
+
+        for (const line of lines) {
+          if (!line.startsWith('data: ')) continue;
+          const data = line.slice(6).trim();
+          if (data === '[DONE]') break;
+
+          try {
+            const parsed = JSON.parse(data);
+            if (parsed?.usage?.total_tokens) {
+              setMainTokenUsage(
+                `Token 消耗:输入 ${parsed.usage.prompt_tokens ?? 0} · 输出 ${parsed.usage.completion_tokens ?? 0} · 总计 ${parsed.usage.total_tokens}`
+              );
+            }
+            const delta = parsed.choices?.[0]?.delta?.content;
+            if (delta) {
+              accumulated += delta;
+              setContent(accumulated);
+              if (textareaRef.current) {
+                textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
+              }
+            }
+          } catch {
+            // 忽略解析错误
+          }
+        }
+      }
+
+      setHasGenerated(true);
+    } catch (err) {
+      if (err instanceof Error && err.name === 'AbortError') return;
+      setError(err instanceof Error ? err.message : '生成章节内容失败,请重试');
+    } finally {
+      setIsGenerating(false);
+    }
+  }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, rollingSummaries]);
+
+  useEffect(() => {
+    return () => {
+      abortControllerRef.current?.abort();
+    };
+  }, []);
+
+  useEffect(() => {
+    fetch(`/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&filePath=world_state/rolling-summaries`)
+      .then(r => r.json())
+      .then(data => {
+        if (data.success && data.files) {
+          setRollingSummaries(data.files.map((f: {content: string}) => f.content));
+        }
+      })
+      .catch(() => {});
+  }, [projectPath]);
+
+  const handleNext = () => {
+    if (!content.trim()) return;
+    onNext(content);
+  };
+
+  return (
+    <div className="flex flex-col h-full">
+      {wordCountWarning && (
+        <div className="flex items-center gap-2 p-3 mb-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
+          <AlertCircle className="h-4 w-4 flex-shrink-0" />
+          {wordCountWarning}
+        </div>
+      )}
+
+      <div className="flex items-center justify-between mb-4">
+        <div>
+          <h2 className="text-xl font-semibold text-gray-900">生成章节内容</h2>
+          <p className="text-sm text-gray-500 mt-1">
+            AI 正在根据剧情方向和钩子生成章节,您可以直接编辑修改
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          <button
+            onClick={handleDeAI}
+            disabled={isDeAI || !apiSettings || !content.trim() || isGenerating}
+            title="对全文执行三步去AI味修订(去模板化→去专业腔→恢复真实语感)"
+            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+          >
+            {isDeAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
+            去AI味
+          </button>
+          <button
+            onClick={handlePolish}
+            disabled={isPolishing || !apiSettings || selectionStart === selectionEnd || isGenerating}
+            title={selectionStart === selectionEnd ? '请先在编辑器中选中文字' : '对选中文字进行润色'}
+            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+          >
+            {isPolishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
+            文本润色
+          </button>
+
+          <button
+            onClick={() => {
+              setShowParagraphPanel(true);
+              setParagraphResult(null);
+              setParagraphError(null);
+            }}
+            disabled={isGenerating || !apiSettings}
+            className="flex items-center gap-1.5 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+          >
+            <PlusSquare className="h-4 w-4" />
+            段落生成
+          </button>
+
+          <button
+            onClick={generateContent}
+            disabled={isGenerating || !apiSettings}
+            className="flex items-center gap-1.5 px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+          >
+            {isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
+            {hasGenerated ? '重新生成' : '生成内容'}
+          </button>
+        </div>
+      </div>
+
+      {deAIError && (
+        <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
+          <AlertCircle className="h-4 w-4 flex-shrink-0" /> 去AI味修订失败: {deAIError}
+          <button onClick={() => setDeAIError(null)} className="ml-auto text-red-500 hover:text-red-700">
+            <X className="h-4 w-4" /></button></div>
+      )}
+
+      {polishError && (
+        <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
+          <AlertCircle className="h-4 w-4 flex-shrink-0" />
+          {polishError}
+          <button onClick={rejectPolish} className="ml-auto text-red-500 hover:text-red-700">
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+      )}
+
+      {polishTokenUsage && (
+        <p className="text-[11px] text-gray-500 mb-3">{polishTokenUsage}</p>
+      )}
+
+      {polishResult && (
+        <div className="mb-4 border border-amber-200 rounded-xl overflow-hidden bg-amber-50">
+          <div className="flex items-center justify-between px-4 py-2 bg-amber-100 border-b border-amber-200">
+            <span className="text-sm font-medium text-amber-800">文本润色结果对比</span>
+            <div className="flex items-center gap-2">
+              <button
+                onClick={acceptPolish}
+                className="flex items-center gap-1 px-3 py-1 text-xs bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
+              >
+                <Check className="h-3 w-3" />
+                接受
+              </button>
+              <button
+                onClick={rejectPolish}
+                className="flex items-center gap-1 px-3 py-1 text-xs bg-gray-500 text-white rounded-md hover:bg-gray-600 transition-colors"
+              >
+                <X className="h-3 w-3" />
+                拒绝
+              </button>
+            </div>
+          </div>
+          <div className="grid grid-cols-2 divide-x divide-amber-200">
+            <div className="p-4">
+              <p className="text-xs text-amber-600 mb-1">原文</p>
+              <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.original}</p>
+            </div>
+            <div className="p-4">
+              <p className="text-xs text-green-600 mb-1">润色后</p>
+              <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.polished}</p>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {showParagraphPanel && (
+        <div className="mb-4 border border-emerald-200 rounded-xl overflow-hidden bg-emerald-50">
+          <div className="flex items-center justify-between px-4 py-2 bg-emerald-100 border-b border-emerald-200">
+            <span className="text-sm font-medium text-emerald-800">段落生成</span>
+            <button onClick={() => setShowParagraphPanel(false)} className="text-emerald-600 hover:text-emerald-800">
+              <X className="h-4 w-4" />
+            </button>
+          </div>
+          <div className="p-4 space-y-3">
+            <input
+              type="text"
+              value={paragraphTopic}
+              onChange={(e) => setParagraphTopic(e.target.value)}
+              placeholder="输入段落主题..."
+              className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500"
+            />
+            <textarea
+              value={paragraphRequirements}
+              onChange={(e) => setParagraphRequirements(e.target.value)}
+              placeholder="可选:输入额外要求..."
+              className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none h-20"
+            />
+            {paragraphError && (
+              <p className="text-xs text-red-600">{paragraphError}</p>
+            )}
+            {paragraphTokenUsage && (
+              <p className="text-[11px] text-gray-500">{paragraphTokenUsage}</p>
+            )}
+            <div className="flex gap-2">
+              <button
+                onClick={handleGenerateParagraph}
+                disabled={isGeneratingParagraph || !apiSettings}
+                className="flex items-center gap-1.5 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
+              >
+                {isGeneratingParagraph ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
+                生成段落
+              </button>
+              {paragraphResult && (
+                <button
+                  onClick={acceptParagraph}
+                  className="flex items-center gap-1 px-3 py-2 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
+                >
+                  <Check className="h-3 w-3" />
+                  插入光标位置
+                </button>
+              )}
+              {paragraphResult && (
+                <button
+                  onClick={rejectParagraph}
+                  className="flex items-center gap-1 px-3 py-2 text-xs bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
+                >
+                  <X className="h-3 w-3" />
+                  取消
+                </button>
+              )}
+            </div>
+            {paragraphResult && (
+              <div className="p-3 bg-white border border-emerald-200 rounded-lg">
+                <p className="text-xs text-emerald-600 mb-1">生成的段落</p>
+                <p className="text-sm text-gray-700 whitespace-pre-wrap">{paragraphResult}</p>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+
+      <div className="flex-1 flex flex-col min-h-0 mb-4">
+        <div className="flex items-center justify-between mb-2">
+          <span className="text-sm font-medium text-gray-700">
+            章节内容 {wordCount} / {wordCountMin}-{wordCountMax} 字
+          </span>
+        </div>
+        <textarea
+          ref={textareaRef}
+          value={content}
+          onChange={(e) => setContent(e.target.value)}
+          onSelect={(e) => {
+            const target = e.target as HTMLTextAreaElement;
+            setSelectionStart(target.selectionStart);
+            setSelectionEnd(target.selectionEnd);
+          }}
+          onClick={(e) => {
+            const target = e.target as HTMLTextAreaElement;
+            setCursorPosition(target.selectionStart);
+          }}
+          onKeyUp={(e) => {
+            const target = e.target as HTMLTextAreaElement;
+            setCursorPosition(target.selectionStart);
+          }}
+          placeholder="点击「生成内容」按钮让 AI 创作本章..."
+          className="flex-1 w-full px-4 py-3 text-sm border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500 font-serif leading-relaxed"
+          disabled={isGenerating}
+        />
+      </div>
+
+      {mainTokenUsage && (
+        <p className="text-[11px] text-gray-500 mb-3">{mainTokenUsage}</p>
+      )}
+
+      {error && (
+        <div className="flex items-center gap-2 p-3 mb-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
+          <AlertCircle className="h-4 w-4 flex-shrink-0" />
+          {error}
+        </div>
+      )}
+
+      <div className="mt-auto pt-4 border-t border-gray-200 flex items-center justify-between">
+        <button
+          onClick={onBack}
+          className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
+        >
+          <ChevronLeft className="h-4 w-4" />
+          上一步:解决钩子
+        </button>
+        <button
+          onClick={handleNext}
+          disabled={!content.trim() || isGenerating}
+          className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+        >
+          下一步:审核提交
+          <ChevronRight className="h-4 w-4" />
+        </button>
+      </div>
+    </div>
+  );
 }