Просмотр исходного кода

feat: marathon pre-flight setup, auto-processing pipeline, historical immersion context in Step4

- Add immersion hooks context from world_state/immersion-hooks/ to Step4ContentEditor
  generation prompts (both full chapter and paragraph generation)
- Add marathon setup dialog with target chapter count, plot outline,
  AI-generated batch outline, and start/cancel workflow
- Replace simple marathon checkbox with a guided setup dialog
- Add auto-processing (autoMode) pipeline where each step auto-executes
  without user input when marathon mode is active
- Add auto-execution logic to all step components (Step0-6)
  with visual banner indicating auto-mode status

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 месяцев назад
Родитель
Сommit
5d15c82b1c

+ 37 - 0
src/app/features/novel-writer/components/ChapterDraftPipeline.tsx

@@ -30,6 +30,10 @@ interface ChapterDraftPipelineProps {
   recentChaptersCount: number;
   chapterEvents?: EventNode[];
   ironRules?: string;
+  autoMode?: boolean;
+  marathonChapterIndex?: number;
+  marathonTarget?: number;
+  onStopAuto?: () => void;
   onComplete: (chapterTitle: string, content: string) => void;
   onCancel: () => void;
 }
@@ -65,6 +69,10 @@ export default function ChapterDraftPipeline({
   recentChaptersCount,
   onComplete,
   onCancel,
+  autoMode = false,
+  marathonChapterIndex,
+  marathonTarget,
+  onStopAuto,
 }: ChapterDraftPipelineProps) {
   const storageKey = `pipeline_${projectPath}_${chapterIndex}`;
 
@@ -304,6 +312,29 @@ export default function ChapterDraftPipeline({
         </div>
       </div>
 
+      {/* Auto-mode banner */}
+      {autoMode && (
+        <div className="flex-shrink-0 bg-indigo-600 text-white px-6 py-2 flex items-center justify-between">
+          <div className="flex items-center gap-2 text-sm">
+            <svg className="w-4 h-4 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+            <span>马拉松模式 — 自动创作中</span>
+            <span className="text-indigo-200">
+              (第 {marathonChapterIndex || chapterIndex}/{marathonTarget && marathonTarget > 0 ? marathonTarget : '∞'} 章)
+            </span>
+          </div>
+          {onStopAuto && (
+            <button
+              onClick={onStopAuto}
+              className="text-xs px-3 py-1 bg-white/20 hover:bg-white/30 rounded-md transition-colors"
+            >
+              停止自动
+            </button>
+          )}
+        </div>
+      )}
+
       {/* 主内容区域 */}
       <div className="flex-1 overflow-hidden">
         <div className="max-w-4xl mx-auto px-6 py-6 h-full flex flex-col">
@@ -316,6 +347,7 @@ export default function ChapterDraftPipeline({
               savedControlCard={pipelineState.controlCard}
               chapterEvents={chapterEvents}
               onNext={handleStep0Next}
+              autoMode={autoMode}
             />
           )}
 
@@ -328,6 +360,7 @@ export default function ChapterDraftPipeline({
               apiSettings={apiSettings}
               onNext={handleStep1Next}
               onBack={() => setCurrentStep(0)}
+              autoMode={autoMode}
             />
           )}
 
@@ -349,6 +382,7 @@ export default function ChapterDraftPipeline({
               chapterEvents={chapterEvents}
               onNext={handleStep2Next}
               onBack={() => setCurrentStep(1)}
+              autoMode={autoMode}
             />
           )}
 
@@ -365,6 +399,7 @@ export default function ChapterDraftPipeline({
               manualHooks={pipelineState.manualHooks}
               onComplete={handleStep3Complete}
               onBack={() => setCurrentStep(2)}
+              autoMode={autoMode}
             />
           )}
 
@@ -378,6 +413,7 @@ export default function ChapterDraftPipeline({
               chapterEvents={chapterEvents}
               onComplete={handleStep4Complete}
               onBack={() => setCurrentStep(3)}
+              autoMode={autoMode}
             />
           )}
 
@@ -390,6 +426,7 @@ export default function ChapterDraftPipeline({
               apiSettings={apiSettings}
               onComplete={handleStep5Complete}
               onSkip={handleStep5Complete}
+              autoMode={autoMode}
             />
           )}
         </div>

+ 156 - 27
src/app/features/novel-writer/components/NovelContextManager.tsx

@@ -106,6 +106,10 @@ export default function NovelContextManager({
   const [marathonMode, setMarathonMode] = useState(false);
   const [marathonTarget, setMarathonTarget] = useState(0); // 0 = no limit
   const [marathonPaused, setMarathonPaused] = useState(false);
+  const [showMarathonSetup, setShowMarathonSetup] = useState(false);
+  const [marathonSetupTarget, setMarathonSetupTarget] = useState(20);
+  const [marathonOutline, setMarathonOutline] = useState('');
+  const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
   const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
     if (typeof window !== 'undefined') {
       return localStorage.getItem(TEMPLATE_STORAGE_KEY) || PROMPT_TEMPLATES[0].id;
@@ -201,6 +205,75 @@ export default function NovelContextManager({
     [saveProjectConfig]
   );
 
+  // ── Marathon outline generation ──
+  const handleGenerateMarathonOutline = useCallback(async () => {
+    if (!apiSettings) return;
+    setIsGeneratingOutline(true);
+    try {
+      const graphRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+      const graphData = await graphRes.json();
+
+      const prompt = `你是一位小说大纲规划师。请为第${chapters.length + 1}章到第${marathonSetupTarget}章生成一个逐章大纲。
+
+## 事件图谱
+${JSON.stringify(graphData.events || [], null, 2)}
+
+## 章节分配
+${JSON.stringify(graphData.chapterAllocations || [], null, 2)}
+
+## 要求
+- 每章写1-2句话,概括本章的核心情节
+- 确保事件图谱中分配的事件都在对应章节中体现
+- 如有伏笔,标注埋入章节和回收章节
+- 每个"卷"(约10章)有一个主题概括
+
+## 输出格式
+用 Markdown,每章一行:
+- 第N章:一句话概括`;
+
+      const res = 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 }], max_tokens: 3000 },
+          isOllama: apiSettings.apiUrl.includes('11434'),
+        }),
+      });
+      const data = await res.json();
+      setMarathonOutline(data.choices?.[0]?.message?.content || '');
+    } catch {
+      // ignore
+    } finally {
+      setIsGeneratingOutline(false);
+    }
+  }, [apiSettings, projectPath, chapters.length, marathonSetupTarget]);
+
+  // ── Start marathon ──
+  const handleStartMarathon = useCallback(async () => {
+    // Save the marathon outline
+    if (marathonOutline.trim()) {
+      try {
+        await fetch('/api/novel/files', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            action: 'write',
+            projectPath,
+            filePath: 'world_state/marathon-outline.md',
+            content: marathonOutline,
+          }),
+        });
+      } catch { /* ignore */ }
+    }
+    setMarathonTarget(marathonSetupTarget);
+    setMarathonMode(true);
+    setMarathonPaused(false);
+    setShowMarathonSetup(false);
+    setShowPipeline(true);
+  }, [projectPath, marathonOutline, marathonSetupTarget]);
+
   // ── Chapter pipeline ──
   const handleStartNewChapter = () => {
     setShowPipeline(true);
@@ -316,12 +389,17 @@ export default function NovelContextManager({
         recentChaptersCount={recentChaptersCount}
         onComplete={handlePipelineComplete}
         onCancel={handlePipelineCancel}
+        autoMode={marathonMode}
+        marathonChapterIndex={chapters.length + 1}
+        marathonTarget={marathonTarget}
+        onStopAuto={() => setMarathonMode(false)}
       />
     );
   }
 
   // ── Main layout ──
   return (
+    <>
     <div className="flex h-screen bg-gray-50 overflow-hidden">
       {/* Main area */}
       <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
@@ -501,33 +579,16 @@ export default function NovelContextManager({
                   </span>
                 </div>
               ) : (
-                /* Inactive marathon mode — checkbox + target input */
-                <div className="flex items-center gap-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg">
-                  <label className="flex items-center gap-2 cursor-pointer">
-                    <input
-                      type="checkbox"
-                      checked={marathonMode}
-                      onChange={(e) => setMarathonMode(e.target.checked)}
-                      className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
-                    />
-                    <span className="text-xs text-gray-600">马拉松模式</span>
-                  </label>
-                  <div className="flex items-center gap-1.5">
-                    <span className="text-xs text-gray-500">目标:</span>
-                    <input
-                      type="number"
-                      min={0}
-                      value={marathonTarget || ''}
-                      onChange={(e) => {
-                        const val = parseInt(e.target.value, 10);
-                        setMarathonTarget(isNaN(val) || val < 0 ? 0 : val);
-                      }}
-                      placeholder="∞"
-                      className="w-16 px-2 py-1 text-xs text-gray-700 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-indigo-500"
-                    />
-                    <span className="text-xs text-gray-500">章</span>
-                  </div>
-                </div>
+                /* Inactive marathon mode — setup button */
+                <button
+                  onClick={() => setShowMarathonSetup(true)}
+                  className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 transition-colors"
+                >
+                  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
+                  </svg>
+                  马拉松模式
+                </button>
               )}
 
               {/* Pulsing indicator when marathon is active and pipeline is not showing */}
@@ -608,5 +669,73 @@ export default function NovelContextManager({
         )}
       </div>
     </div>
+
+    {/* Marathon Setup Dialog */}
+    {showMarathonSetup && (
+      <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={() => setShowMarathonSetup(false)}>
+        <div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
+          <div className="px-6 py-5 border-b border-gray-200">
+            <h2 className="text-lg font-semibold text-gray-900">马拉松模式设置</h2>
+            <p className="text-sm text-gray-500 mt-1">设置目标章节数和大纲,AI 将自动续写</p>
+          </div>
+
+          <div className="px-6 py-5 space-y-5">
+            {/* Chapter target */}
+            <div>
+              <label className="block text-sm font-medium text-gray-700 mb-1.5">目标章节数</label>
+              <input
+                type="number"
+                min={1}
+                value={marathonSetupTarget}
+                onChange={(e) => setMarathonSetupTarget(Math.max(1, parseInt(e.target.value) || 1))}
+                className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
+              />
+              <p className="text-xs text-gray-400 mt-1">从第 {chapters.length + 1} 章开始,共续写至 {marathonSetupTarget} 章</p>
+            </div>
+
+            {/* Plot outline */}
+            <div>
+              <label className="block text-sm font-medium text-gray-700 mb-1.5">批量大纲(可选)</label>
+              <textarea
+                value={marathonOutline}
+                onChange={(e) => setMarathonOutline(e.target.value)}
+                placeholder="在此填写逐章大纲,或点击下方按钮让 AI 生成..."
+                className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none h-40"
+              />
+            </div>
+
+            {/* AI Generate Outline */}
+            <button
+              onClick={handleGenerateMarathonOutline}
+              disabled={isGeneratingOutline || !apiSettings}
+              className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors"
+            >
+              {isGeneratingOutline ? (
+                <><span className="w-4 h-4 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" /> 生成中...</>
+              ) : (
+                <><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> AI 生成逐章大纲</>
+              )}
+            </button>
+          </div>
+
+          {/* Actions */}
+          <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-end gap-3">
+            <button
+              onClick={() => setShowMarathonSetup(false)}
+              className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
+            >
+              取消
+            </button>
+            <button
+              onClick={handleStartMarathon}
+              className="px-5 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
+            >
+              开始马拉松创作
+            </button>
+          </div>
+        </div>
+      </div>
+    )}
+    </>
   );
 }

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

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { Sparkles, ArrowRight, ChevronDown, ChevronUp } from 'lucide-react';
 import type { EventNode } from '@/app/lib/novel-types';
 
@@ -17,6 +17,7 @@ interface Step0ControlCardProps {
   savedControlCard: ControlCardData | null;
   chapterEvents?: EventNode[];
   onNext: (data: ControlCardData) => void;
+  autoMode?: boolean;
 }
 
 export interface ControlCardData {
@@ -47,6 +48,7 @@ export default function Step0ControlCard({
   savedControlCard,
   chapterEvents,
   onNext,
+  autoMode = false,
 }: Step0ControlCardProps) {
   const [whatChanges, setWhatChanges] = useState(savedControlCard?.whatChanges || '');
   const [lineToAdvance, setLineToAdvance] = useState(savedControlCard?.lineToAdvance || '');
@@ -63,6 +65,15 @@ export default function Step0ControlCard({
   const [chapterPlans, setChapterPlans] = useState<ChapterPlan[]>([]);
   const [selectedPlanIndex, setSelectedPlanIndex] = useState<number | null>(null);
   const [expandedPlanIndex, setExpandedPlanIndex] = useState<number | null>(null);
+  const autoModeStartedRef = useRef(false);
+
+  // Auto-mode: auto-trigger generation on mount
+  useEffect(() => {
+    if (autoMode && apiSettings && !autoModeStartedRef.current && !savedControlCard) {
+      autoModeStartedRef.current = true;
+      handleGenerate();
+    }
+  }, [autoMode]); // eslint-disable-line react-hooks/exhaustive-deps
 
   const handleGenerate = async () => {
     if (!apiSettings) return;
@@ -182,6 +193,23 @@ ${projectContext || '(项目文档不可用)'}
 
       // Auto-select the first plan and fill fields
       applyPlan(plans[0], 0);
+
+      // Auto-mode: auto-advance after generation
+      if (autoMode) {
+        const firstPlan = plans[0];
+        setTimeout(() => {
+          onNext({
+            whatChanges: firstPlan.whatChanges,
+            lineToAdvance: firstPlan.lineToAdvance,
+            debtToReturn: firstPlan.debtToReturn,
+            chapterConflict: firstPlan.chapterConflict,
+            endingResidue: firstPlan.endingResidue,
+            chapterTitleCandidates: firstPlan.titleCandidates,
+            workingTitle: firstPlan.titleCandidates[0] || '',
+            rawAIResponse: content,
+          });
+        }, 800);
+      }
     } catch (err) {
       const msg = err instanceof Error ? err.message : '未知错误';
       console.error('章节方案生成失败:', msg, err);

+ 23 - 1
src/app/features/novel-writer/components/pipeline/Step2EmbedHooks.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
 import { RefreshCw, ChevronRight, ChevronLeft, Plus, Loader2, AlertCircle, CheckSquare, Square } from 'lucide-react';
 
 interface ApiSettings {
@@ -23,6 +23,7 @@ interface Step2EmbedHooksProps {
   apiSettings: ApiSettings | null;
   onNext: (embedHooks: HookOption[], manualHooks: string[]) => void;
   onBack: () => void;
+  autoMode?: boolean;
 }
 
 export default function Step2EmbedHooks({
@@ -32,6 +33,7 @@ export default function Step2EmbedHooks({
   apiSettings,
   onNext,
   onBack,
+  autoMode = false,
 }: Step2EmbedHooksProps) {
   const [hookOptions, setHookOptions] = useState<HookOption[]>(savedEmbedHooks);
   const [isGenerating, setIsGenerating] = useState(false);
@@ -40,6 +42,26 @@ export default function Step2EmbedHooks({
   const [manualHook, setManualHook] = useState('');
   const [manualHooks, setManualHooks] = useState<string[]>(savedManualHooks);
   const [tokenUsage, setTokenUsage] = useState<string | null>(null);
+  const autoModeStartedRef = useRef(false);
+
+  // Auto-mode: auto-trigger generation on mount
+  useEffect(() => {
+    if (autoMode && apiSettings && !autoModeStartedRef.current && hookOptions.length === 0) {
+      autoModeStartedRef.current = true;
+      generateHookOptions();
+    }
+  }, [autoMode]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // Auto-mode: auto-advance after hooks are generated
+  useEffect(() => {
+    if (autoMode && hookOptions.length > 0 && !isGenerating) {
+      const selectedHooks = hookOptions.filter((h) => selectedHookIds.has(h.id));
+      const timer = setTimeout(() => {
+        onNext(selectedHooks, manualHooks);
+      }, 500);
+      return () => clearTimeout(timer);
+    }
+  }, [autoMode, hookOptions, isGenerating, selectedHookIds, manualHooks, onNext]);
 
   const generateHookOptions = useCallback(async () => {
     if (!apiSettings) {

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

@@ -27,6 +27,7 @@ interface Step4ContentEditorProps {
   chapterEvents?: EventNode[];
   onNext: (content: string) => void;
   onBack: () => void;
+  autoMode?: boolean;
 }
 
 const ANTI_HOMOGENIZATION_LABELS: Record<AntiHomogenizationStrategy, string> = {
@@ -91,6 +92,7 @@ export default function Step4ContentEditor({
   chapterEvents = [],
   onNext,
   onBack,
+  autoMode = false,
 }: Step4ContentEditorProps) {
   const [content, setContent] = useState(savedChapterContent);
   const [isGenerating, setIsGenerating] = useState(false);
@@ -190,6 +192,38 @@ export default function Step4ContentEditor({
         } catch { /* file may not exist */ }
       }
 
+      // Fetch recent immersion hooks from immersion-hooks directory
+      try {
+        const listRes = await fetch('/api/novel/files', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ action: 'list', projectPath, directory: 'world_state/immersion-hooks' }),
+        });
+        if (listRes.ok) {
+          const listData = await listRes.json();
+          const files = (listData.files || []) as string[];
+          const sortedFiles = files
+            .filter((f: string) => f.endsWith('.md'))
+            .sort((a: string, b: string) => {
+              const ai = parseInt(a.match(/chapter-(\d+)/)?.[1] || '0');
+              const bi = parseInt(b.match(/chapter-(\d+)/)?.[1] || '0');
+              return bi - ai;
+            });
+          const recentFiles = sortedFiles.slice(0, 3);
+          for (const file of recentFiles) {
+            const readRes = await fetch('/api/novel/files', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ action: 'read', projectPath, filePath: `world_state/immersion-hooks/${file}` }),
+            });
+            if (readRes.ok) {
+              const readData = await readRes.json();
+              if (readData.content) contextText += `\n【近期代入感设计】\n${readData.content}\n`;
+            }
+          }
+        }
+      } catch { /* ignore */ }
+
       const antiDriftSection = ironRules ? `## 项目铁律\n${ironRules}` : '';
 
       return `${worldContext}${contextText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
@@ -401,7 +435,42 @@ ${antiText}
         ? `要求:${paragraphRequirements.trim()}\n`
         : '';
 
-      const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
+      // Fetch immersion hooks context for paragraph generation
+      let immersionSection = '';
+      try {
+        const listRes = await fetch('/api/novel/files', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ action: 'list', projectPath, directory: 'world_state/immersion-hooks' }),
+        });
+        if (listRes.ok) {
+          const listData = await listRes.json();
+          const files = (listData.files || []) as string[];
+          const sortedFiles = files
+            .filter((f: string) => f.endsWith('.md'))
+            .sort((a: string, b: string) => {
+              const ai = parseInt(a.match(/chapter-(\d+)/)?.[1] || '0');
+              const bi = parseInt(b.match(/chapter-(\d+)/)?.[1] || '0');
+              return bi - ai;
+            });
+          const recentFiles = sortedFiles.slice(0, 3);
+          for (const file of recentFiles) {
+            const readRes = await fetch('/api/novel/files', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ action: 'read', projectPath, filePath: `world_state/immersion-hooks/${file}` }),
+            });
+            if (readRes.ok) {
+              const readData = await readRes.json();
+              if (readData.content) immersionSection += '\n\n' + readData.content;
+            }
+          }
+        }
+      } catch { /* ignore */ }
+
+      const immersionContext = immersionSection.trim() ? `## 近期代入感设计\n${immersionSection.trim()}\n\n` : '';
+
+      const prompt = `${immersionContext}请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
 
       const response = await fetch('/api/proxy', {
         method: 'POST',
@@ -584,6 +653,25 @@ ${antiText}
       .catch(() => {});
   }, [projectPath]);
 
+  // Auto-mode: auto-trigger generation on mount
+  const autoContentStartedRef = useRef(false);
+  useEffect(() => {
+    if (autoMode && apiSettings && !autoContentStartedRef.current && !content && !isGenerating) {
+      autoContentStartedRef.current = true;
+      generateContent();
+    }
+  }, [autoMode]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // Auto-mode: auto-advance after generation completes
+  useEffect(() => {
+    if (autoMode && hasGenerated && content.trim() && !isGenerating) {
+      const timer = setTimeout(() => {
+        onNext(content);
+      }, 2000);
+      return () => clearTimeout(timer);
+    }
+  }, [autoMode, hasGenerated, content, isGenerating, onNext]);
+
   const handleNext = () => {
     if (!content.trim()) return;
     onNext(content);

+ 26 - 1
src/app/features/novel-writer/components/pipeline/Step5ReviewSubmit.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import { ChevronLeft, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Save, Sparkles } from 'lucide-react';
 import { WorldStateFile, WorldLoreFile, FileUpdateResult, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
 import { useStateUpdater } from '@/app/hooks/useStateUpdater';
@@ -22,6 +22,7 @@ interface Step5ReviewSubmitProps {
   manualHooks: string[];
   onComplete: (chapterTitle: string) => void;
   onBack: () => void;
+  autoMode?: boolean;
 }
 
 function DiffViewer({
@@ -184,6 +185,7 @@ export default function Step5ReviewSubmit({
   manualHooks,
   onComplete,
   onBack,
+  autoMode = false,
 }: Step5ReviewSubmitProps) {
   const [chapterTitle, setChapterTitle] = useState(`第${chapterIndex}章`);
   const [hasStartedUpdate, setHasStartedUpdate] = useState(false);
@@ -191,6 +193,7 @@ export default function Step5ReviewSubmit({
   const [saveSuccess, setSaveSuccess] = useState(false);
   const [saveError, setSaveError] = useState<string | null>(null);
   const [hookWarning, setHookWarning] = useState<string | null>(null);
+  const autoModeConfirmedRef = useRef(false);
 
 
   const stateUpdaterApiSettings = apiSettings
@@ -226,6 +229,28 @@ export default function Step5ReviewSubmit({
     }
   }, [embedHooks, manualHooks]);
 
+  // Auto-mode: confirm all pending and auto-save
+  useEffect(() => {
+    if (!autoMode) return;
+
+    const hasGenerating = updateResults.some(r => r.status === 'generating');
+    const hasPending = updateResults.some(r => r.status === 'pending');
+    const allDone = updateResults.length > 0 && updateResults.every(r => r.status === 'confirmed' || r.status === 'rejected' || r.status === 'error');
+
+    // Step 1: Once updates are done generating, confirm all pending
+    if (updateResults.length > 0 && !hasGenerating && hasPending && !autoModeConfirmedRef.current && !isSaving && !saveSuccess) {
+      autoModeConfirmedRef.current = true;
+      for (const result of updateResults.filter(r => r.status === 'pending')) {
+        confirmUpdate(result.fileName);
+      }
+    }
+
+    // Step 2: Once all are confirmed, auto-save
+    if (allDone && !isSaving && !saveSuccess && autoModeConfirmedRef.current) {
+      handleSaveChapter();
+    }
+  }, [autoMode, updateResults, isSaving, saveSuccess, isUpdating]); // eslint-disable-line react-hooks/exhaustive-deps
+
   const allReviewed =
     updateResults.length > 0 &&
     updateResults.every((r) => r.status === 'confirmed' || r.status === 'rejected');

+ 44 - 1
src/app/features/novel-writer/components/pipeline/Step6ContinuityCheck.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { Shield, ArrowLeft, Check, AlertTriangle } from 'lucide-react';
 import { CONTINUITY_CHECK_ITEMS, buildContinuityCheckPrompt } from '@/app/lib/chapter-prompts';
 import type { EventNode } from '@/app/lib/novel-types';
@@ -19,6 +19,7 @@ interface Step6ContinuityCheckProps {
   chapterEvents?: EventNode[];
   onComplete: () => void;
   onBack: () => void;
+  autoMode?: boolean;
 }
 
 interface CheckResult {
@@ -35,6 +36,7 @@ export default function Step6ContinuityCheck({
   chapterEvents,
   onComplete,
   onBack,
+  autoMode = false,
 }: Step6ContinuityCheckProps) {
   const [selected, setSelected] = useState<Set<string>>(new Set(CONTINUITY_CHECK_ITEMS.map((c) => c.id)));
   const [checking, setChecking] = useState(false);
@@ -46,6 +48,47 @@ export default function Step6ContinuityCheck({
 
   const issueCount = results ? results.filter(r => r.status === 'issue').length : 0;
   const hasIssues = issueCount > 0;
+  const autoCheckRef = useRef(false);
+
+  // Auto-mode: auto-run check and auto-fix
+  useEffect(() => {
+    if (!autoMode) return;
+
+    // Step 1: Auto-trigger check
+    if (!autoCheckRef.current && !checking && !results && !fixResult && !fixing) {
+      autoCheckRef.current = true;
+      handleCheck();
+      return;
+    }
+
+    // Step 2: Results are back — if no issues, auto-complete
+    if (results && !hasIssues && !checking && !fixing) {
+      const timer = setTimeout(() => onComplete(), 500);
+      return () => clearTimeout(timer);
+    }
+
+    // Step 3: Results have issues — auto-fix
+    if (results && hasIssues && !checking && !fixing && !fixResult) {
+      handleAutoFix();
+      return;
+    }
+
+    // Step 4: Fix result is ready — apply and re-check
+    if (fixResult && !fixing && !checking) {
+      setFixApplied(true);
+      setResults(null);
+      const content = fixResult;
+      setFixResult(null);
+      setTimeout(() => handleCheck(content), 100);
+      return;
+    }
+
+    // Step 5: Re-check results — no issues or still has issues, advance
+    if (fixApplied && results && !checking) {
+      const timer = setTimeout(() => onComplete(), 500);
+      return () => clearTimeout(timer);
+    }
+  }, [autoMode, checking, results, hasIssues, fixResult, fixApplied, fixing, onComplete]); // eslint-disable-line react-hooks/exhaustive-deps
 
   const handleAutoFix = async () => {
     if (!apiSettings || !results || !chapterContent) return;

+ 27 - 2
src/app/features/novel-writer/components/pipeline/Step7ReaderFeedback.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { Check, SkipForward, Star } from 'lucide-react';
 
 interface Step7ReaderFeedbackProps {
@@ -10,6 +10,7 @@ interface Step7ReaderFeedbackProps {
   apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
   onComplete: () => void;
   onSkip: () => void;
+  autoMode?: boolean;
 }
 
 interface Feedback {
@@ -21,14 +22,38 @@ interface Feedback {
 }
 
 export default function Step7ReaderFeedback({
-  projectPath, chapterIndex, chapterContent, apiSettings, onComplete, onSkip,
+  projectPath, chapterIndex, chapterContent, apiSettings, onComplete, onSkip, autoMode = false,
 }: Step7ReaderFeedbackProps) {
   const [feedback, setFeedback] = useState<Feedback | null>(null);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
+  const autoFeedbackRef = useRef(false);
 
   const shouldRun = chapterIndex % 5 === 0;
 
+  // Auto-mode: auto-advance through feedback step
+  useEffect(() => {
+    if (!autoMode || autoFeedbackRef.current) return;
+    autoFeedbackRef.current = true;
+
+    if (!shouldRun) {
+      const timer = setTimeout(() => onSkip(), 300);
+      return () => clearTimeout(timer);
+    }
+  }, [autoMode]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  useEffect(() => {
+    if (!autoMode || !feedback) return;
+    const timer = setTimeout(() => onComplete(), 1000);
+    return () => clearTimeout(timer);
+  }, [autoMode, feedback, onComplete]);
+
+  useEffect(() => {
+    if (!autoMode || !error) return;
+    const timer = setTimeout(() => onSkip(), 300);
+    return () => clearTimeout(timer);
+  }, [autoMode, error, onSkip]);
+
   useEffect(() => {
     if (!shouldRun || !apiSettings) return;
     setLoading(true);