ソースを参照

refactor: rename hooks to immersion system, remove foreshadowing type, remove resolve tracking, MD-per-chapter storage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 ヶ月 前
親
コミット
17284e626f

+ 1 - 1
src/app/api/novel/files/route.ts

@@ -92,7 +92,7 @@ export async function GET(request: NextRequest) {
       const allEntries = await fs.readdir(worldStatePath).catch(() => [] as string[]);
       const disabledFiles = await getDisabledWorldStateFiles(projectPath);
       for (const entry of allEntries) {
-        if (!entry.endsWith('.md') && entry !== 'narrative_hooks.json') continue;
+        if (!entry.endsWith('.md')) continue;
         const filePath = path.join(worldStatePath, entry);
         try {
           const stat = await fs.stat(filePath);

+ 41 - 78
src/app/features/novel-writer/components/ChapterDraftPipeline.tsx

@@ -2,16 +2,14 @@
 
 import { useState, useCallback, useEffect } from 'react';
 import { X, Check } from 'lucide-react';
-import { WorldStateFile, WorldLoreFile, WritingConstraint, AntiHomogenizationStrategy, NarrativeHook, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
-import { parseHooksFromJson } from '@/app/lib/novel-utils';
+import { WorldStateFile, WorldLoreFile, WritingConstraint, AntiHomogenizationStrategy, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
 import Step0ControlCard from './pipeline/Step0ControlCard';
 import Step1PlotSelection from './pipeline/Step1PlotSelection';
 import Step2EmbedHooks from './pipeline/Step2EmbedHooks';
-import Step3ResolveHooks from './pipeline/Step3ResolveHooks';
-import Step4ContentEditor from './pipeline/Step4ContentEditor';
-import Step5ReviewSubmit from './pipeline/Step5ReviewSubmit';
-import Step6ContinuityCheck from './pipeline/Step6ContinuityCheck';
-import Step7ReaderFeedback from './pipeline/Step7ReaderFeedback';
+import Step3ContentEditor from './pipeline/Step4ContentEditor';
+import Step4ReviewSubmit from './pipeline/Step5ReviewSubmit';
+import Step5ContinuityCheck from './pipeline/Step6ContinuityCheck';
+import Step6ReaderFeedback from './pipeline/Step7ReaderFeedback';
 import type { ControlCardData } from './pipeline/Step0ControlCard';
 
 interface ApiSettings {
@@ -37,7 +35,7 @@ interface ChapterDraftPipelineProps {
   onCancel: () => void;
 }
 
-type PipelineStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
+type PipelineStep = 0 | 1 | 2 | 3 | 4 | 5 | 6;
 
 interface PipelineState {
   controlCard: ControlCardData | null;
@@ -45,7 +43,6 @@ interface PipelineState {
   customNote: string;
   embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
   manualHooks: string[];
-  resolveHookIds: string[];
   chapterContent: string;
 }
 
@@ -53,11 +50,10 @@ const STEPS = [
   { id: 0, label: '控制卡' },
   { id: 1, label: '选择剧情' },
   { id: 2, label: '埋入钩子' },
-  { id: 3, label: '解决钩子' },
-  { id: 4, label: '生成内容' },
-  { id: 5, label: '审核提交' },
-  { id: 6, label: '连续性检查' },
-  { id: 7, label: '读者反馈' },
+  { id: 3, label: '生成内容' },
+  { id: 4, label: '审核提交' },
+  { id: 5, label: '连续性检查' },
+  { id: 6, label: '读者反馈' },
 ] as const;
 
 export default function ChapterDraftPipeline({
@@ -88,7 +84,6 @@ export default function ChapterDraftPipeline({
           customNote: parsed.customNote || '',
           embedHooks: parsed.embedHooks || [],
           manualHooks: parsed.manualHooks || [],
-          resolveHookIds: parsed.resolveHookIds || [],
           chapterContent: parsed.chapterContent || '',
         };
       }
@@ -99,7 +94,6 @@ export default function ChapterDraftPipeline({
       customNote: '',
       embedHooks: [],
       manualHooks: [],
-      resolveHookIds: [],
       chapterContent: '',
     };
   };
@@ -125,13 +119,6 @@ export default function ChapterDraftPipeline({
     });
   };
 
-  // 从 worldStateFiles 中提取 open 状态的叙事钩子
-  const openHooks: NarrativeHook[] = (() => {
-    const hooksFile = worldStateFiles.find((f) => f.fileName === 'narrative_hooks.json');
-    if (!hooksFile || !hooksFile.content.trim()) return [];
-    return parseHooksFromJson(hooksFile.content).filter((h) => h.status === 'open');
-  })();
-
   // 加载事件图谱和防漂移文件
   useEffect(() => {
     // Load event graph allocation for this chapter
@@ -186,26 +173,14 @@ export default function ChapterDraftPipeline({
     []
   );
 
-  // Step 3 完成(解决钩子)
-  const handleStep3Next = useCallback(
-    (resolveHookIds: string[]) => {
-      updateState((prev) => ({
-        ...prev,
-        resolveHookIds,
-      }));
-      setCurrentStep(4);
-    },
-    []
-  );
-
-  // Step 4 完成
-  const handleStep4Next = useCallback((content: string) => {
+  // Step 3 完成(内容生成,原 Step 4)
+  const handleStep3Next = useCallback((content: string) => {
     updateState((prev) => ({ ...prev, chapterContent: content }));
-    setCurrentStep(5);
+    setCurrentStep(4);
   }, []);
 
-  // Step 5 完成 → 进入连续性检查
-  const handleStep5Complete = useCallback(
+  // Step 4 完成 → 进入连续性检查
+  const handleStep4Complete = useCallback(
     (chapterTitle: string) => {
       // Save the chapter title into control card for use in final completion
       updateState((prev) => ({
@@ -214,15 +189,15 @@ export default function ChapterDraftPipeline({
           ? { ...prev.controlCard, workingTitle: prev.controlCard.workingTitle || chapterTitle }
           : { whatChanges: '', lineToAdvance: '', debtToReturn: '', chapterConflict: '', endingResidue: '', chapterTitleCandidates: [], workingTitle: chapterTitle, rawAIResponse: '' },
       }));
-      setCurrentStep(6);
+      setCurrentStep(5);
     },
     []
   );
 
-  // Step 6 完成 → 条件性进入 Step 7(每5章一次)或直接完成
-  const handleStep6Complete = useCallback(() => {
+  // Step 5 完成 → 条件性进入 Step 6(每5章一次)或直接完成
+  const handleStep5Complete = useCallback(() => {
     if (chapterIndex % 5 === 0) {
-      setCurrentStep(7);
+      setCurrentStep(6);
     } else {
       sessionStorage.removeItem(storageKey);
       const title = pipelineState.controlCard?.workingTitle || `第${chapterIndex}章`;
@@ -230,8 +205,8 @@ export default function ChapterDraftPipeline({
     }
   }, [onComplete, pipelineState.chapterContent, pipelineState.controlCard, chapterIndex, storageKey]);
 
-  // Step 7 完成 → 最终提交
-  const handleStep7Complete = useCallback(() => {
+  // Step 6 完成 → 最终提交
+  const handleStep6Complete = useCallback(() => {
     sessionStorage.removeItem(storageKey);
     const title = pipelineState.controlCard?.workingTitle || `第${chapterIndex}章`;
     onComplete(title, pipelineState.chapterContent);
@@ -242,7 +217,7 @@ export default function ChapterDraftPipeline({
     onCancel();
   };
 
-  // 构建传给 Step4 的完整剧情描述(含补充说明)
+  // 构建传给 Step3 的完整剧情描述(含补充说明)
   const fullPlotDescription = pipelineState.customNote
     ? `${pipelineState.selectedPlot}\n\n补充说明:${pipelineState.customNote}`
     : pipelineState.selectedPlot;
@@ -366,24 +341,13 @@ export default function ChapterDraftPipeline({
             />
           )}
 
-          {/* Step 3: 解决钩子 */}
+          {/* Step 3: 内容生成 */}
           {currentStep === 3 && (
-            <Step3ResolveHooks
-              openHooks={openHooks}
-              savedResolveHookIds={pipelineState.resolveHookIds}
-              onNext={handleStep3Next}
-              onBack={() => setCurrentStep(2)}
-            />
-          )}
-
-          {/* Step 4: 内容生成 */}
-          {currentStep === 4 && (
-            <Step4ContentEditor
+            <Step3ContentEditor
               projectPath={projectPath}
               selectedPlot={fullPlotDescription}
               embedHooks={pipelineState.embedHooks}
               manualHooks={pipelineState.manualHooks}
-              resolveHookIds={pipelineState.resolveHookIds}
               savedChapterContent={pipelineState.chapterContent}
               apiSettings={apiSettings}
               writingConstraints={writingConstraints}
@@ -392,14 +356,14 @@ export default function ChapterDraftPipeline({
               wordCountMax={wordCountMax}
               recentChaptersCount={recentChaptersCount}
               ironRules={ironRulesContent}
-              onNext={handleStep4Next}
-              onBack={() => setCurrentStep(3)}
+              onNext={handleStep3Next}
+              onBack={() => setCurrentStep(2)}
             />
           )}
 
-          {/* Step 5: 审核提交 */}
-          {currentStep === 5 && (
-            <Step5ReviewSubmit
+          {/* Step 4: 审核提交 */}
+          {currentStep === 4 && (
+            <Step4ReviewSubmit
               projectPath={projectPath}
               chapterContent={pipelineState.chapterContent}
               chapterIndex={chapterIndex}
@@ -408,34 +372,33 @@ export default function ChapterDraftPipeline({
               loreFiles={loreFiles}
               embedHooks={pipelineState.embedHooks}
               manualHooks={pipelineState.manualHooks}
-              resolveHookIds={pipelineState.resolveHookIds}
-              onComplete={handleStep5Complete}
-              onBack={() => setCurrentStep(4)}
+              onComplete={handleStep4Complete}
+              onBack={() => setCurrentStep(3)}
             />
           )}
 
-          {/* Step 6: 连续性检查 */}
-          {currentStep === 6 && (
-            <Step6ContinuityCheck
+          {/* Step 5: 连续性检查 */}
+          {currentStep === 5 && (
+            <Step5ContinuityCheck
               projectPath={projectPath}
               chapterIndex={chapterIndex}
               chapterContent={pipelineState.chapterContent}
               apiSettings={apiSettings}
               chapterEvents={chapterEvents}
-              onComplete={handleStep6Complete}
-              onBack={() => setCurrentStep(5)}
+              onComplete={handleStep5Complete}
+              onBack={() => setCurrentStep(4)}
             />
           )}
 
-          {/* Step 7: 读者反馈 */}
-          {currentStep === 7 && (
-            <Step7ReaderFeedback
+          {/* Step 6: 读者反馈 */}
+          {currentStep === 6 && (
+            <Step6ReaderFeedback
               projectPath={projectPath}
               chapterIndex={chapterIndex}
               chapterContent={pipelineState.chapterContent}
               apiSettings={apiSettings}
-              onComplete={handleStep7Complete}
-              onSkip={handleStep7Complete}
+              onComplete={handleStep6Complete}
+              onSkip={handleStep6Complete}
             />
           )}
         </div>

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

@@ -73,7 +73,7 @@ const DISABLABLE_WORLD_STATE_FILES: { key: string; label: string }[] = [
   { key: 'protagonist_inventory.md', label: '物品栏' },
   { key: 'character_relations.md', label: '角色关系' },
   { key: 'world_notes.md', label: '世界笔记' },
-  { key: 'narrative_hooks.json', label: '叙事钩子' },
+  { key: 'immersion-hooks.md', label: '代入感系统' },
   { key: 'writing_style.md', label: '文风档案' },
 ];
 

+ 114 - 221
src/app/features/novel-writer/components/NarrativeHookPanel.tsx

@@ -1,9 +1,7 @@
 'use client';
 
-import { useState, useCallback } from 'react';
-import { NarrativeHook } from '@/app/lib/novel-types';
-import { parseHooksFromJson, deleteHook, addHooks } from '@/app/lib/novel-utils';
-import { useWorldState } from '@/app/hooks/useWorldState';
+import { useState, useEffect } from 'react';
+import { FileText, Loader2 } from 'lucide-react';
 
 // ─────────────────────────────────────────────
 // Types
@@ -14,52 +12,9 @@ interface NarrativeHookPanelProps {
   latestChapterIndex: number;
 }
 
-type HookType = NarrativeHook['type'];
-
-const HOOK_TYPE_OPTIONS: { value: HookType; label: string }[] = [
-  { value: 'suspense', label: '悬念' },
-  { value: 'foreshadowing', label: '伏笔' },
-  { value: 'author_reader_gap', label: '作者读者信息差' },
-  { value: 'reader_protagonist_gap', label: '读者主角信息差' },
-];
-
-const HOOK_TYPE_LABELS: Record<HookType, string> = {
-  suspense: '悬念',
-  foreshadowing: '伏笔',
-  author_reader_gap: '作者读者信息差',
-  reader_protagonist_gap: '读者主角信息差',
-};
-
-// ─────────────────────────────────────────────
-// Sub-component: NarrativeHookCard
-// ─────────────────────────────────────────────
-
-interface NarrativeHookCardProps {
-  hook: NarrativeHook;
-  onDelete: (hookId: string) => void;
-}
-
-function NarrativeHookCard({ hook, onDelete }: NarrativeHookCardProps) {
-  return (
-    <div className="rounded-lg bg-orange-50 border border-orange-200 px-3 py-2 space-y-1">
-      <div className="flex items-center gap-2">
-        <span className="text-xs font-medium text-orange-700 bg-orange-100 px-1.5 py-0.5 rounded">
-          {HOOK_TYPE_LABELS[hook.type]}
-        </span>
-        <span className="text-xs text-orange-500">第 {hook.introducedInChapter} 章埋入</span>
-        <button
-          onClick={() => onDelete(hook.id)}
-          className="ml-auto text-orange-400 hover:text-red-500 transition-colors"
-          aria-label="删除钩子"
-        >
-          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-          </svg>
-        </button>
-      </div>
-      <p className="text-xs text-orange-800 leading-relaxed">{hook.description}</p>
-    </div>
-  );
+interface ImmersionFile {
+  chapterIndex: number;
+  content: string;
 }
 
 // ─────────────────────────────────────────────
@@ -67,201 +22,139 @@ function NarrativeHookCard({ hook, onDelete }: NarrativeHookCardProps) {
 // ─────────────────────────────────────────────
 
 export default function NarrativeHookPanel({ projectPath, latestChapterIndex }: NarrativeHookPanelProps) {
-  const {
-    worldStateFiles,
-    updateFile,
-    refreshFiles,
-  } = useWorldState(projectPath);
-
-  // Add hook form state
-  const [showAddHookForm, setShowAddHookForm] = useState(false);
-  const [newHookType, setNewHookType] = useState<HookType>('suspense');
-  const [newHookDescription, setNewHookDescription] = useState('');
-  const [newHookChapter, setNewHookChapter] = useState<number>(latestChapterIndex);
-  const [isAddingHook, setIsAddingHook] = useState(false);
-  const [addHookError, setAddHookError] = useState<string | null>(null);
-
-  // Parse narrative hooks from narrative_hooks.json
-  const narrativeHooksFile = worldStateFiles.find((f) => f.fileName === 'narrative_hooks.json');
-  const allHooks = narrativeHooksFile ? parseHooksFromJson(narrativeHooksFile.content) : [];
-  const openHooks = allHooks.filter((h) => h.status === 'open');
-  const resolvedHooks = allHooks.filter((h) => h.status === 'resolved');
+  const [immersionFiles, setImmersionFiles] = useState<ImmersionFile[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
 
-  // ── Handlers ──
-
-  const handleAddHook = async () => {
-    if (!newHookDescription.trim()) {
-      setAddHookError('请输入钩子描述');
-      return;
-    }
+  useEffect(() => {
+    loadImmersionFiles();
+  }, [projectPath, latestChapterIndex]);
 
-    setIsAddingHook(true);
-    setAddHookError(null);
+  const loadImmersionFiles = async () => {
+    setLoading(true);
+    setError(null);
 
     try {
-      const id = `hook-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
-
-      const newHook: NarrativeHook = {
-        id,
-        type: newHookType,
-        description: newHookDescription.trim(),
-        introducedInChapter: newHookChapter,
-        status: 'open',
-      };
-
-      const existingContent = narrativeHooksFile?.content ?? '';
-      const updatedContent = addHooks(existingContent, [newHook]);
-
-      await updateFile('narrative_hooks.json', updatedContent, false);
-      await refreshFiles();
-
-      setNewHookDescription('');
-      setNewHookType('suspense');
-      setNewHookChapter(latestChapterIndex);
-      setShowAddHookForm(false);
+      // List files in world_state/immersion-hooks/
+      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',
+        }),
+      });
+      const listData = await listRes.json();
+
+      if (!listData.success) {
+        // Directory may not exist yet
+        setImmersionFiles([]);
+        setLoading(false);
+        return;
+      }
+
+      const mdFiles: string[] = (listData.files || []).filter((f: string) => f.endsWith('.md'));
+      // Sort by chapter index (newest first), keep last 3
+      const sorted = mdFiles.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 = sorted.slice(0, 3);
+      const files: ImmersionFile[] = [];
+
+      for (const fileName of recentFiles) {
+        const ci = parseInt(fileName.match(/chapter-(\d+)/)?.[1] || '0');
+        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/${fileName}`,
+          }),
+        });
+        const readData = await readRes.json();
+        if (readData.success && readData.content) {
+          files.push({ chapterIndex: ci, content: readData.content as string });
+        }
+      }
+
+      // Sort by chapter index ascending for display
+      files.sort((a, b) => a.chapterIndex - b.chapterIndex);
+      setImmersionFiles(files);
     } catch (err) {
-      setAddHookError(err instanceof Error ? err.message : '添加钩子失败');
+      setError(err instanceof Error ? err.message : '加载代入感文件失败');
     } finally {
-      setIsAddingHook(false);
+      setLoading(false);
     }
   };
 
-  const handleDeleteHook = useCallback(
-    async (hookId: string) => {
-      if (!narrativeHooksFile) return;
-
-      const updatedContent = deleteHook(narrativeHooksFile.content, hookId);
-      await updateFile('narrative_hooks.json', updatedContent, false);
-      await refreshFiles();
-    },
-    [narrativeHooksFile, updateFile, refreshFiles]
-  );
-
   return (
     <div className="flex-1 overflow-y-auto p-5">
       <div className="max-w-2xl mx-auto space-y-6">
         {/* Header */}
         <div className="flex items-center justify-between">
           <div>
-            <h2 className="text-base font-semibold text-gray-800">叙事钩子</h2>
+            <h2 className="text-base font-semibold text-gray-800">代入感系统</h2>
             <p className="text-xs text-gray-500 mt-0.5">
-              管理故事中的悬念、伏笔和信息差,追踪哪些钩子已回收
+              查看最近 3 章的代入感设计记录(悬念/信息差),由创作管线自动生成
             </p>
           </div>
-          {openHooks.length > 0 && (
-            <span className="text-xs font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full">
-              {openHooks.length} 个未解决
-            </span>
-          )}
-        </div>
-
-        {/* Open hooks list */}
-        <div>
-          <h3 className="text-sm font-medium text-gray-700 mb-2">未解决的钩子</h3>
-          {openHooks.length === 0 ? (
-            <p className="text-xs text-gray-400 text-center py-4 bg-gray-50 rounded-lg border border-gray-100">
-              暂无未解决钩子
-            </p>
-          ) : (
-            <div className="space-y-2">
-              {openHooks.map((hook) => (
-                <NarrativeHookCard key={hook.id} hook={hook} onDelete={handleDeleteHook} />
-              ))}
-            </div>
-          )}
+          <button
+            onClick={loadImmersionFiles}
+            disabled={loading}
+            className="flex items-center gap-1 px-3 py-1.5 text-xs text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
+          >
+            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+            </svg>
+            刷新
+          </button>
         </div>
 
-        {/* Add hook form */}
-        <div className="border border-orange-200 rounded-xl p-4 bg-orange-50">
-          {!showAddHookForm ? (
-            <button
-              onClick={() => { setShowAddHookForm(true); setAddHookError(null); setNewHookChapter(latestChapterIndex); }}
-              className="w-full flex items-center justify-center gap-1.5 py-2 text-sm text-gray-500 border border-dashed border-orange-300 rounded-lg hover:border-orange-400 hover:text-orange-500 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="M12 4v16m8-8H4" />
-              </svg>
-              手动添加钩子
-            </button>
-          ) : (
-            <div className="space-y-3">
-              <p className="text-sm font-medium text-orange-700">添加叙事钩子</p>
-
-              {/* Hook type select */}
-              <select
-                value={newHookType}
-                onChange={(e) => setNewHookType(e.target.value as HookType)}
-                className="w-full text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400"
-              >
-                {HOOK_TYPE_OPTIONS.map((opt) => (
-                  <option key={opt.value} value={opt.value}>
-                    {opt.label}
-                  </option>
-                ))}
-              </select>
-
-              {/* Chapter input */}
-              <div className="flex items-center gap-2">
-                <span className="text-sm text-gray-600 shrink-0">引入章节:</span>
-                <input
-                  type="number"
-                  min="0"
-                  value={newHookChapter}
-                  onChange={(e) => setNewHookChapter(parseInt(e.target.value) || 0)}
-                  className="flex-1 text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400"
-                />
-              </div>
-
-              {/* Description input */}
-              <textarea
-                value={newHookDescription}
-                onChange={(e) => setNewHookDescription(e.target.value)}
-                placeholder="描述这个钩子的内容..."
-                className="w-full text-sm border border-orange-200 rounded-lg px-3 py-2 bg-white focus:outline-none focus:border-orange-400 resize-none h-24"
-              />
+        {/* Loading state */}
+        {loading && (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
+          </div>
+        )}
 
-              {addHookError && (
-                <p className="text-xs text-red-500">{addHookError}</p>
-              )}
+        {/* Error state */}
+        {error && (
+          <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
+            {error}
+          </div>
+        )}
 
-              <div className="flex gap-2">
-                <button
-                  onClick={() => { setShowAddHookForm(false); setAddHookError(null); setNewHookDescription(''); setNewHookChapter(latestChapterIndex); }}
-                  className="flex-1 text-sm py-2 bg-white border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"
-                >
-                  取消
-                </button>
-                <button
-                  onClick={handleAddHook}
-                  disabled={isAddingHook}
-                  className="flex-1 text-sm py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
-                >
-                  {isAddingHook ? '添加中...' : '添加'}
-                </button>
-              </div>
-            </div>
-          )}
-        </div>
+        {/* Empty state */}
+        {!loading && !error && immersionFiles.length === 0 && (
+          <div className="text-center py-12 text-gray-400">
+            <FileText className="h-10 w-10 mx-auto mb-3 text-gray-300" />
+            <p className="text-sm">暂无代入感设计记录</p>
+            <p className="text-xs mt-1">开始创作章节后,管线中的代入感设计会自动保存到这里</p>
+          </div>
+        )}
 
-        {/* Resolved hooks */}
-        {resolvedHooks.length > 0 && (
-          <div>
-            <h3 className="text-sm font-medium text-gray-700 mb-2">已解决的钩子</h3>
-            <div className="space-y-2 opacity-60">
-              {resolvedHooks.map((hook) => (
-                <div key={hook.id} className="rounded-lg bg-green-50 border border-green-200 px-3 py-2 space-y-1">
-                  <div className="flex items-center gap-2">
-                    <span className="text-xs font-medium text-green-700 bg-green-100 px-1.5 py-0.5 rounded">
-                      {HOOK_TYPE_LABELS[hook.type]}
-                    </span>
-                    <span className="text-xs text-green-500">
-                      第 {hook.introducedInChapter} 章埋入 / 第 {hook.resolvedInChapter} 章解决
-                    </span>
-                  </div>
-                  <p className="text-xs text-green-800 leading-relaxed">{hook.description}</p>
+        {/* Immersion files display */}
+        {!loading && immersionFiles.length > 0 && (
+          <div className="space-y-4">
+            {immersionFiles.map((file) => (
+              <div key={file.chapterIndex} className="border border-indigo-100 rounded-xl overflow-hidden">
+                <div className="bg-indigo-50 px-4 py-2 border-b border-indigo-100">
+                  <h3 className="text-sm font-medium text-indigo-800">
+                    第 {file.chapterIndex} 章 - 代入感设计
+                  </h3>
                 </div>
-              ))}
-            </div>
+                <div className="p-4">
+                  <pre className="text-sm text-gray-700 whitespace-pre-wrap font-sans leading-relaxed">
+                    {file.content}
+                  </pre>
+                </div>
+              </div>
+            ))}
           </div>
         )}
       </div>

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

@@ -411,7 +411,7 @@ export default function NovelContextManager({
               {[
                 { id: 'world' as const, label: '世界观编辑' },
                 { id: 'events' as const, label: '事件图' },
-                { id: 'hooks' as const, label: '叙事钩子' },
+                { id: 'hooks' as const, label: '代入感系统' },
               ].map(tab => (
                 <button key={tab.id} onClick={() => setPhase1Tab(tab.id)}
                   className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${

+ 2 - 225
src/app/features/novel-writer/components/WorldStateSidebar.tsx

@@ -1,8 +1,7 @@
 'use client';
 
 import { useState, useCallback, useRef } from 'react';
-import { NarrativeHook, WorldStateFile } from '@/app/lib/novel-types';
-import { parseHooksFromJson, deleteHook, addHooks, resolveHook } from '@/app/lib/novel-utils';
+import { WorldStateFile } from '@/app/lib/novel-types';
 import { useWorldState } from '@/app/hooks/useWorldState';
 
 // ─────────────────────────────────────────────
@@ -16,22 +15,6 @@ interface WorldStateSidebarProps {
   onFileEdit?: (file: WorldStateFile, isLore: boolean) => void;
 }
 
-type HookType = NarrativeHook['type'];
-
-const HOOK_TYPE_OPTIONS: { value: HookType; label: string }[] = [
-  { value: 'suspense', label: '悬念' },
-  { value: 'foreshadowing', label: '伏笔' },
-  { value: 'author_reader_gap', label: '作者读者信息差' },
-  { value: 'reader_protagonist_gap', label: '读者主角信息差' },
-];
-
-const HOOK_TYPE_LABELS: Record<HookType, string> = {
-  suspense: '悬念',
-  foreshadowing: '伏笔',
-  author_reader_gap: '作者读者信息差',
-  reader_protagonist_gap: '读者主角信息差',
-};
-
 // ─────────────────────────────────────────────
 // Helper: format date
 // ─────────────────────────────────────────────
@@ -123,42 +106,6 @@ function FileEntry({ file, isLore, projectPath, onFileEdit }: FileEntryProps) {
   );
 }
 
-// ─────────────────────────────────────────────
-// Sub-component: NarrativeHookCard
-// ─────────────────────────────────────────────
-
-interface NarrativeHookCardProps {
-  hook: NarrativeHook;
-  onDelete: (hookId: string) => void;
-}
-
-function NarrativeHookCard({ hook, onDelete }: NarrativeHookCardProps) {
-  return (
-    <div className="rounded-lg bg-orange-50 border border-orange-200 px-3 py-2 space-y-1">
-      <div className="flex items-center gap-2">
-        <span className="text-xs font-medium text-orange-700 bg-orange-100 px-1.5 py-0.5 rounded">
-          {HOOK_TYPE_LABELS[hook.type]}
-        </span>
-        <span className="text-xs text-orange-500">第 {hook.introducedInChapter} 章埋入</span>
-        <button
-          onClick={() => onDelete(hook.id)}
-          className="ml-auto text-orange-400 hover:text-red-500 transition-colors"
-          aria-label="删除钩子"
-        >
-          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-          </svg>
-        </button>
-      </div>
-      <p className="text-xs text-orange-800 leading-relaxed">{hook.description}</p>
-    </div>
-  );
-}
-
-// ─────────────────────────────────────────────
-// Main component: WorldStateSidebar
-// ─────────────────────────────────────────────
-
 // ─────────────────────────────────────────────
 // Export / Import helpers
 // ─────────────────────────────────────────────
@@ -253,15 +200,6 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
   // Section collapsed states
   const [worldStateCollapsed, setWorldStateCollapsed] = useState(false);
   const [loreCollapsed, setLoreCollapsed] = useState(true);
-  const [hooksCollapsed, setHooksCollapsed] = useState(false);
-
-  // Add hook form state
-  const [showAddHookForm, setShowAddHookForm] = useState(false);
-  const [newHookType, setNewHookType] = useState<HookType>('suspense');
-  const [newHookDescription, setNewHookDescription] = useState('');
-  const [newHookChapter, setNewHookChapter] = useState<number>(latestChapterIndex);
-  const [isAddingHook, setIsAddingHook] = useState(false);
-  const [addHookError, setAddHookError] = useState<string | null>(null);
 
   // Import/export state
   const [isExporting, setIsExporting] = useState(false);
@@ -273,14 +211,9 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
   } | null>(null);
   const importInputRef = useRef<HTMLInputElement>(null);
 
-  // Parse narrative hooks from narrative_hooks.json
-  const narrativeHooksFile = worldStateFiles.find((f) => f.fileName === 'narrative_hooks.json');
-  const allHooks = narrativeHooksFile ? parseHooksFromJson(narrativeHooksFile.content) : [];
-  const openHooks = allHooks.filter((h) => h.status === 'open');
-
   // Separate writing_style.md from other world state files for display
   const displayWorldStateFiles = worldStateFiles.filter(
-    (f) => f.fileName !== 'narrative_hooks.json' && f.fileName !== 'writing_style.md'
+    (f) => f.fileName !== 'immersion-hooks.md' && f.fileName !== 'writing_style.md'
   );
 
   // ── Handlers ──
@@ -292,54 +225,6 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
     [updateFile]
   );
 
-  const handleAddHook = async () => {
-    if (!newHookDescription.trim()) {
-      setAddHookError('请输入钩子描述');
-      return;
-    }
-
-    setIsAddingHook(true);
-    setAddHookError(null);
-
-    try {
-      const id = `hook-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
-
-      const newHook: NarrativeHook = {
-        id,
-        type: newHookType,
-        description: newHookDescription.trim(),
-        introducedInChapter: newHookChapter,
-        status: 'open',
-      };
-
-      const existingContent = narrativeHooksFile?.content ?? '';
-      const updatedContent = addHooks(existingContent, [newHook]);
-
-      await updateFile('narrative_hooks.json', updatedContent, false);
-      await refreshFiles();
-
-      setNewHookDescription('');
-      setNewHookType('suspense');
-      setNewHookChapter(latestChapterIndex);
-      setShowAddHookForm(false);
-    } catch (err) {
-      setAddHookError(err instanceof Error ? err.message : '添加钩子失败');
-    } finally {
-      setIsAddingHook(false);
-    }
-  };
-
-  const handleDeleteHook = useCallback(
-    async (hookId: string) => {
-      if (!narrativeHooksFile) return;
-
-      const updatedContent = deleteHook(narrativeHooksFile.content, hookId);
-      await updateFile('narrative_hooks.json', updatedContent, false);
-      await refreshFiles();
-    },
-    [narrativeHooksFile, updateFile, refreshFiles]
-  );
-
   // ── Export handler ──
 
   const handleExport = useCallback(async () => {
@@ -445,12 +330,6 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
         <span className="text-xs text-gray-400 [writing-mode:vertical-rl] rotate-180 select-none mt-2">
           世界状态
         </span>
-        {/* Open hooks badge */}
-        {openHooks.length > 0 && (
-          <span className="w-5 h-5 rounded-full bg-orange-400 text-white text-xs flex items-center justify-center font-medium">
-            {openHooks.length > 9 ? '9+' : openHooks.length}
-          </span>
-        )}
       </div>
     );
   }
@@ -612,108 +491,6 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
           </div>
         </div>
 
-        {/* ── Narrative Hooks Section ── */}
-        <div className="border-b border-gray-100">
-          {/* Section header */}
-          <button
-            onClick={() => setHooksCollapsed(!hooksCollapsed)}
-            className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 transition-colors text-left"
-          >
-            <svg className={`w-3.5 h-3.5 text-gray-400 transition-transform ${hooksCollapsed ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
-            </svg>
-            <span className="text-xs font-semibold text-gray-600 flex-1">叙事钩子</span>
-            {openHooks.length > 0 && (
-              <span className="text-xs font-medium text-orange-600 bg-orange-100 px-1.5 py-0.5 rounded-full">
-                {openHooks.length} 个未解决
-              </span>
-            )}
-          </button>
-
-          {!hooksCollapsed && (
-            <div className="px-3 pb-3 space-y-2">
-              {/* Open hooks list */}
-              {openHooks.length === 0 ? (
-                <p className="text-xs text-gray-400 text-center py-2">暂无未解决钩子</p>
-              ) : (
-                openHooks.map((hook) => (
-                  <NarrativeHookCard key={hook.id} hook={hook} onDelete={handleDeleteHook} />
-                ))
-              )}
-
-              {/* Add hook button */}
-              {!showAddHookForm ? (
-                <button
-                  onClick={() => { setShowAddHookForm(true); setAddHookError(null); setNewHookChapter(latestChapterIndex); }}
-                  className="w-full flex items-center justify-center gap-1.5 py-1.5 text-xs text-gray-500 border border-dashed border-gray-300 rounded-lg hover:border-orange-400 hover:text-orange-500 transition-colors"
-                >
-                  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
-                  </svg>
-                  手动添加钩子
-                </button>
-              ) : (
-                <div className="border border-orange-200 rounded-lg p-3 bg-orange-50 space-y-2">
-                  <p className="text-xs font-medium text-orange-700">添加叙事钩子</p>
-
-                  {/* Hook type select */}
-                  <select
-                    value={newHookType}
-                    onChange={(e) => setNewHookType(e.target.value as HookType)}
-                    className="w-full text-xs border border-orange-200 rounded px-2 py-1.5 bg-white focus:outline-none focus:border-orange-400"
-                  >
-                    {HOOK_TYPE_OPTIONS.map((opt) => (
-                      <option key={opt.value} value={opt.value}>
-                        {opt.label}
-                      </option>
-                    ))}
-                  </select>
-
-                  {/* Chapter input */}
-                  <div className="flex items-center gap-2">
-                    <span className="text-xs text-gray-600 shrink-0">引入章节:</span>
-                    <input
-                      type="number"
-                      min="0"
-                      value={newHookChapter}
-                      onChange={(e) => setNewHookChapter(parseInt(e.target.value) || 0)}
-                      className="flex-1 text-xs border border-orange-200 rounded px-2 py-1.5 bg-white focus:outline-none focus:border-orange-400"
-                    />
-                  </div>
-
-                  {/* Description input */}
-                  <textarea
-                    value={newHookDescription}
-                    onChange={(e) => setNewHookDescription(e.target.value)}
-                    placeholder="描述这个钩子的内容..."
-                    className="w-full text-xs border border-orange-200 rounded px-2 py-1.5 bg-white focus:outline-none focus:border-orange-400 resize-none h-20"
-                  />
-
-                  {addHookError && (
-                    <p className="text-xs text-red-500">{addHookError}</p>
-                  )}
-
-                  <div className="flex gap-2">
-                    <button
-                      onClick={() => { setShowAddHookForm(false); setAddHookError(null); setNewHookDescription(''); setNewHookChapter(latestChapterIndex); }}
-                      className="flex-1 text-xs py-1.5 bg-white border border-gray-200 text-gray-600 rounded hover:bg-gray-50 transition-colors"
-                    >
-                      取消
-                    </button>
-                    <button
-                      onClick={handleAddHook}
-                      disabled={isAddingHook}
-                      className="flex-1 text-xs py-1.5 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors disabled:opacity-50"
-                    >
-                      {isAddingHook ? '添加中...' : '添加'}
-                    </button>
-                  </div>
-                </div>
-              )}
-            </div>
-          )}
-        </div>
-
         {/* ── World State Files Section ── */}
         <div className="border-b border-gray-100">
           <button

+ 9 - 10
src/app/features/novel-writer/components/pipeline/Step2EmbedHooks.tsx

@@ -52,12 +52,12 @@ export default function Step2EmbedHooks({
     setTokenUsage(null);
 
     try {
-      const prompt = `根据以下选定的剧情方向,为本章生成 5-8 个叙事钩子/信息差候选项。
+      const prompt = `根据以下选定的剧情方向,为本章生成 5-8 个代入感候选项。
 
 选定剧情方向:
 ${selectedPlot}
 
-请生成 5-8 个本章可以埋入的叙事钩子,每个包含:钩子类型(悬念/伏笔/作者读者信息差/读者主角信息差)、描述、预期在本章中的埋入方式。
+请生成 5-8 个本章可以埋入的代入感设计,每个包含:类型(悬念/作者读者信息差/读者主角信息差)、描述、预期在本章中的埋入方式。
 
 请以 JSON 数组格式返回,格式为:
 [{"id": "hook-1", "type": "悬念", "description": "...", "embedMethod": "..."}]`;
@@ -140,7 +140,6 @@ ${selectedPlot}
 
   const hookTypeColors: Record<string, string> = {
     悬念: 'bg-orange-100 text-orange-700',
-    伏笔: 'bg-blue-100 text-blue-700',
     作者读者信息差: 'bg-purple-100 text-purple-700',
     读者主角信息差: 'bg-green-100 text-green-700',
   };
@@ -149,9 +148,9 @@ ${selectedPlot}
     <div className="flex flex-col h-full">
       <div className="flex items-center justify-between mb-6">
         <div>
-          <h2 className="text-xl font-semibold text-gray-900">选择埋入的钩子</h2>
+          <h2 className="text-xl font-semibold text-gray-900">选择埋入的代入感设计</h2>
           <p className="text-sm text-gray-500 mt-1">
-            选择本章要埋入的叙事钩子/信息差,可多选,也可手动添加
+            选择本章要埋入的代入感(悬念/信息差),可多选,也可手动添加
           </p>
         </div>
         <button
@@ -184,7 +183,7 @@ ${selectedPlot}
           <div>
             <div className="flex items-center justify-between mb-3">
               <h3 className="text-sm font-medium text-gray-700">
-                AI 生成的钩子选项({selectedHookIds.size}/{hookOptions.length} 已选)
+                AI 生成的代入感选项({selectedHookIds.size}/{hookOptions.length} 已选)
               </h3>
               <div className="flex gap-2">
                 <button
@@ -259,13 +258,13 @@ ${selectedPlot}
           <div className="flex items-center justify-center py-8">
             <div className="text-center text-gray-400">
               <div className="text-3xl mb-2">🪝</div>
-              <p className="text-sm">点击{'"'}生成钩子选项{'"'}让 AI 为本章推荐叙事钩子</p>
+              <p className="text-sm">点击{'"'}生成钩子选项{'"'}让 AI 为本章推荐代入感设计</p>
             </div>
           </div>
         )}
 
         <div>
-          <h3 className="text-sm font-medium text-gray-700 mb-3">手动添加钩子</h3>
+          <h3 className="text-sm font-medium text-gray-700 mb-3">手动添加代入感描述</h3>
           <div className="flex gap-2">
             <input
               type="text"
@@ -314,13 +313,13 @@ ${selectedPlot}
           上一步
         </button>
         <div className="text-xs text-gray-400">
-          已选 {selectedHookIds.size} 个钩子,手动添加 {manualHooks.length} 个
+          已选 {selectedHookIds.size} 项,手动添加 {manualHooks.length} 项
         </div>
         <button
           onClick={handleNext}
           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 transition-colors"
         >
-          下一步:选择解决的钩子
+          下一步:生成内容
           <ChevronRight className="h-4 w-4" />
         </button>
       </div>

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

@@ -16,7 +16,6 @@ interface Step4ContentEditorProps {
   selectedPlot: string;
   embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
   manualHooks: string[];
-  resolveHookIds: string[];
   savedChapterContent: string;
   apiSettings: ApiSettings | null;
   writingConstraints: WritingConstraint[];
@@ -68,7 +67,7 @@ function buildLayeredContext(
   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;
+    if (file.fileName === 'immersion-hooks.md' || file.fileName === 'writing_style.md') continue;
     parts.push(`## ${file.displayName || file.fileName}\n${file.content.slice(0, 2000)}`);
   }
 
@@ -80,7 +79,6 @@ export default function Step4ContentEditor({
   selectedPlot,
   embedHooks,
   manualHooks,
-  resolveHookIds,
   savedChapterContent,
   apiSettings,
   writingConstraints,
@@ -149,11 +147,7 @@ export default function Step4ContentEditor({
         ? `\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 hooksText = embedHooksText + manualHooksText;
 
       const constraintsText = enabledConstraints
         ? `\n写作约束(必须满足):\n${enabledConstraints}`
@@ -213,10 +207,9 @@ ${antiText}
 1. 严格遵循上方世界状态和文风设定
 2. 完整呈现选定的剧情方向
 3. 自然地埋入所有选定的叙事钩子
-4. 如果有需要解决的历史钩子,在本章中合理地揭示或解决
-5. 满足所有写作约束
-6. 体现反同质化策略,避免风格同质化
-7. 直接输出章节正文,不要包含标题或额外说明
+4. 满足所有写作约束
+5. 体现反同质化策略,避免风格同质化
+6. 直接输出章节正文,不要包含标题或额外说明
 
 请开始创作:`;
     },
@@ -225,7 +218,6 @@ ${antiText}
       selectedPlot,
       embedHooks,
       manualHooks,
-      resolveHookIds,
       writingConstraints,
       antiHomogenizationStrategies,
       wordCountMin,
@@ -840,7 +832,7 @@ ${antiText}
           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}

+ 59 - 63
src/app/features/novel-writer/components/pipeline/Step5ReviewSubmit.tsx

@@ -2,9 +2,8 @@
 
 import { useState, useEffect, useCallback } from 'react';
 import { ChevronLeft, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Save, Sparkles } from 'lucide-react';
-import { WorldStateFile, WorldLoreFile, FileUpdateResult, NarrativeHook, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
+import { WorldStateFile, WorldLoreFile, FileUpdateResult, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
 import { useStateUpdater } from '@/app/hooks/useStateUpdater';
-import { resolveHook, addHooks } from '@/app/lib/novel-utils';
 
 interface ApiSettings {
   apiUrl: string;
@@ -21,7 +20,6 @@ interface Step5ReviewSubmitProps {
   loreFiles: WorldLoreFile[];
   embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
   manualHooks: string[];
-  resolveHookIds: string[];
   onComplete: (chapterTitle: string) => void;
   onBack: () => void;
 }
@@ -184,7 +182,6 @@ export default function Step5ReviewSubmit({
   loreFiles,
   embedHooks,
   manualHooks,
-  resolveHookIds,
   onComplete,
   onBack,
 }: Step5ReviewSubmitProps) {
@@ -225,7 +222,7 @@ export default function Step5ReviewSubmit({
   useEffect(() => {
     if (embedHooks.length === 0 && manualHooks.length === 0) {
       setHookWarning(
-        '提示:本章未选择叙事钩子,建议在章节中增加叙事张力元素(悬念、伏笔等)'
+        '提示:本章未选择代入感设计(悬念/信息差),建议在章节中增加叙事张力元素'
       );
     }
   }, [embedHooks, manualHooks]);
@@ -317,8 +314,8 @@ export default function Step5ReviewSubmit({
     setSaveError(null);
 
     try {
-      // 1. 处理钩子更新
-      await updateHooksInJson();
+      // 1. 保存代入感设计文件
+      await saveImmersionHooks();
 
       // 2. After successful chapter save, mark chapter events as written
       try {
@@ -354,79 +351,78 @@ export default function Step5ReviewSubmit({
     }
   }, [chapterTitle, chapterIndex, projectPath, chapterContent, onComplete]);
 
-  const updateHooksInJson = async () => {
-    // 读取当前的钩子文件
-    let hooksJsonContent = '';
-    try {
-      const readRes = await fetch('/api/novel/files', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          action: 'read',
-          projectPath,
-          filePath: 'world_state/narrative_hooks.json',
-        }),
-      });
-      const readData = await readRes.json();
-      if (readData.success && typeof readData.content === 'string') {
-        hooksJsonContent = readData.content;
-      }
-    } catch {
-      hooksJsonContent = '';
-    }
-
-    // 1. 添加埋入的钩子
-    if (embedHooks.length > 0 || manualHooks.length > 0) {
-      const newHooks: NarrativeHook[] = [];
+  const saveImmersionHooks = async () => {
+    // Save hooks as Markdown to world_state/immersion-hooks/chapter-{N}.md
+    const hooksLines: string[] = [`# 代入感设计 - 第${chapterIndex}章\n`];
 
+    if (embedHooks.length > 0) {
+      hooksLines.push('## 代入感设计');
       for (const hook of embedHooks) {
-        const typeMap: Record<string, NarrativeHook['type']> = {
-          '悬念': 'suspense',
-          '伏笔': 'foreshadowing',
-          '作者读者信息差': 'author_reader_gap',
-          '读者主角信息差': 'reader_protagonist_gap',
-        };
-        newHooks.push({
-          id: `hook-${chapterIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
-          type: typeMap[hook.type] || 'suspense',
-          description: hook.description,
-          introducedInChapter: chapterIndex,
-          status: 'open',
-        });
+        hooksLines.push(`- **${hook.type}**: ${hook.description}`);
+        if (hook.embedMethod) {
+          hooksLines.push(`  - 埋入方式:${hook.embedMethod}`);
+        }
       }
+      hooksLines.push('');
+    }
 
+    if (manualHooks.length > 0) {
+      hooksLines.push('## 手动添加');
       for (const desc of manualHooks) {
-        newHooks.push({
-          id: `hook-${chapterIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
-          type: 'suspense',
-          description: desc,
-          introducedInChapter: chapterIndex,
-          status: 'open',
-        });
-      }
-
-      if (newHooks.length > 0) {
-        hooksJsonContent = addHooks(hooksJsonContent, newHooks);
+        hooksLines.push(`- ${desc}`);
       }
+      hooksLines.push('');
     }
 
-    // 2. 解决指定的钩子
-    for (const hookId of resolveHookIds) {
-      const result = resolveHook(hooksJsonContent, hookId, chapterIndex);
-      hooksJsonContent = result.updatedHooksFile;
-    }
+    const content = hooksLines.join('\n');
 
-    // 3. 写回文件
     await fetch('/api/novel/files', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         action: 'write',
         projectPath,
-        filePath: 'world_state/narrative_hooks.json',
-        content: hooksJsonContent,
+        filePath: `world_state/immersion-hooks/chapter-${chapterIndex}.md`,
+        content,
       }),
     });
+
+    // Clean up immersion files older than 3 chapters
+    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',
+        }),
+      });
+      const listData = await listRes.json();
+      if (listData.success && Array.isArray(listData.files)) {
+        const mdFiles = listData.files.filter((f: string) => f.endsWith('.md'));
+        // Sort by chapter index extracted from filename
+        const sorted = mdFiles.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; // newest first
+        });
+        // Keep only the last 3
+        for (const oldFile of sorted.slice(3)) {
+          await fetch('/api/novel/files', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({
+              action: 'delete',
+              projectPath,
+              filePath: `world_state/immersion-hooks/${oldFile}`,
+            }),
+          }).catch(() => {});
+        }
+      }
+    } catch {
+      // directory may not exist yet — ignore
+    }
   };
 
   return (

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

@@ -59,7 +59,6 @@ export default function Step6ContinuityCheck({
         'plot_summary.md',
         'character_relations.md',
         'world_notes.md',
-        'narrative_hooks.json',
         'writing_style.md',
         'world_state/lore/worldbuilding.md',
       ];

+ 2 - 2
src/app/hooks/useStateUpdater.ts

@@ -170,8 +170,8 @@ export function useStateUpdater(
       }
 
       // 获取所有启用的文件,但排除不需要每章更新的文件
-      // narrative_hooks.json 由钩子系统更新,writing_style.md 不需要每章更新
-      const excludedFiles = ['narrative_hooks.json', 'writing_style.md'];
+      // immersion-hooks.md 固定模板不每章更新,writing_style.md 不需要每章更新
+      const excludedFiles = ['immersion-hooks.md', 'writing_style.md'];
       const enabledWorldStateFiles = worldStateFiles.filter((f) => f.enabled && !excludedFiles.includes(f.fileName));
       const enabledLoreFiles = loreFiles.filter((f) => f.enabled && !excludedFiles.includes(f.fileName));
       const allEnabledFiles = [...enabledWorldStateFiles, ...enabledLoreFiles];

+ 1 - 1
src/app/lib/__tests__/novel-utils.test.ts

@@ -271,7 +271,7 @@ describe('Property 2: world state file template non-emptiness', () => {
       ['protagonist_inventory.md', '物品'],
       ['character_relations.md', '角色'],
       ['world_notes.md', '世界'],
-      ['narrative_hooks.json', 'hooks'],
+      ['immersion-hooks.md', '代入感'],
       ['writing_style.md', '文风'],
       ['worldbuilding.md', '世界观'],
       ['geography.md', '地理'],

+ 0 - 192
src/app/lib/__tests__/resolve-hook.test.ts

@@ -1,192 +0,0 @@
-/**
- * 属性测试:resolveHook 钩子解决逻辑
- * Property Tests for resolveHook hook resolution logic
- *
- * Feature: novel-context-manager
- * Task: 14.2
- */
-
-// Feature: novel-context-manager, Property 11: hook resolved and removed from open list
-
-import { describe, it, expect } from 'vitest';
-import * as fc from 'fast-check';
-import { resolveHook, parseHooksFromJson, serializeHooksToJson } from '../novel-utils';
-import type { NarrativeHook } from '../novel-types';
-
-// ─────────────────────────────────────────────
-// Task 14.2 — Property 11: hook resolved and removed from open list
-// Feature: novel-context-manager, Property 11: hook resolved and removed from open list
-// ─────────────────────────────────────────────
-
-/**
- * Validates: Requirements 9.2, 9.3
- *
- * 对于任意包含若干 open 状态钩子的 narrative_hooks.md,
- * 将其中某个钩子 H 标记为 resolved 后:
- * 1. H 不应出现在 updatedHooksFile 中(通过 parseNarrativeHooks 验证)
- * 2. resolvedEntry 应包含 H 的 id 和描述
- * 3. resolvedEntry 中应包含解决章节序号
- */
-
-// Arbitrary for a single open NarrativeHook with safe description (no newlines, no markdown bold)
-const openHookArb: fc.Arbitrary<NarrativeHook> = fc.record({
-  id: fc.uuid(),
-  type: fc.constantFrom(
-    'suspense' as const,
-    'foreshadowing' as const,
-    'author_reader_gap' as const,
-    'reader_protagonist_gap' as const
-  ),
-  description: fc
-    .string({ minLength: 1, maxLength: 100 })
-    .filter(
-      (s) =>
-        !s.includes('\n') &&
-        !s.includes('**') &&
-        s.trim() !== '' &&
-        s === s.trim()
-    ),
-  introducedInChapter: fc.integer({ min: 1, max: 100 }),
-  status: fc.constant('open' as const),
-});
-
-/**
- * Build a narrative_hooks.json content string from an array of hooks
- */
-function buildHooksFileContent(hooks: NarrativeHook[]): string {
-  return serializeHooksToJson(hooks);
-}
-
-describe('Property 11: hook resolved and removed from open list', () => {
-  it('should remove the resolved hook from updatedHooksFile and include it in resolvedEntry', () => {
-    fc.assert(
-      fc.property(
-        // Generate 1-5 open hooks
-        fc.array(openHookArb, { minLength: 1, maxLength: 5 }),
-        // Generate a chapter index for resolution
-        fc.integer({ min: 1, max: 200 }),
-        (hooks, chapterIndex) => {
-          // Ensure all hook IDs are unique (fast-check may generate duplicates)
-          const uniqueHooks = hooks.filter(
-            (h, i, arr) => arr.findIndex((x) => x.id === h.id) === i
-          );
-          if (uniqueHooks.length === 0) return;
-
-          // Build the hooks file content
-          const hooksFileContent = buildHooksFileContent(uniqueHooks);
-
-          // Randomly pick one hook to resolve (use first hook for determinism in property)
-          // We use the first hook to keep the property simple and deterministic
-          const targetHook = uniqueHooks[0];
-
-          // Call resolveHook
-          const { updatedHooksFile, resolvedEntry } = resolveHook(
-            hooksFileContent,
-            targetHook.id,
-            chapterIndex
-          );
-
-          // Property 1: The resolved hook should be marked as resolved in updatedHooksFile
-          const remainingHooks = parseHooksFromJson(updatedHooksFile);
-          const foundHook = remainingHooks.find((h) => h.id === targetHook.id);
-          expect(
-            foundHook,
-            `Hook ${targetHook.id} should still be present in updatedHooksFile`
-          ).toBeDefined();
-          expect(
-            foundHook!.status,
-            `Hook ${targetHook.id} should be marked as resolved`
-          ).toBe('resolved');
-          expect(
-            foundHook!.resolvedInChapter,
-            `Hook ${targetHook.id} should record resolvedInChapter`
-          ).toBe(chapterIndex);
-
-          // Property 2: resolvedEntry should contain the hook's id and description
-          expect(
-            resolvedEntry,
-            `resolvedEntry should contain hook id: ${targetHook.id}`
-          ).toContain(targetHook.id);
-          expect(
-            resolvedEntry,
-            `resolvedEntry should contain hook description: ${targetHook.description}`
-          ).toContain(targetHook.description);
-
-          // Property 3: resolvedEntry should contain the resolution chapter index
-          expect(
-            resolvedEntry,
-            `resolvedEntry should contain chapterIndex: ${chapterIndex}`
-          ).toContain(String(chapterIndex));
-        }
-      ),
-      { numRuns: 100 }
-    );
-  });
-
-  it('should preserve other open hooks in updatedHooksFile after resolving one', () => {
-    fc.assert(
-      fc.property(
-        // Generate 2-5 open hooks so there is always at least one remaining
-        fc.array(openHookArb, { minLength: 2, maxLength: 5 }),
-        fc.integer({ min: 1, max: 200 }),
-        (hooks, chapterIndex) => {
-          // Ensure all hook IDs are unique
-          const uniqueHooks = hooks.filter(
-            (h, i, arr) => arr.findIndex((x) => x.id === h.id) === i
-          );
-          if (uniqueHooks.length < 2) return;
-
-          const hooksFileContent = buildHooksFileContent(uniqueHooks);
-          const targetHook = uniqueHooks[0];
-          const otherHooks = uniqueHooks.slice(1);
-
-          const { updatedHooksFile } = resolveHook(
-            hooksFileContent,
-            targetHook.id,
-            chapterIndex
-          );
-
-          const remainingHooks = parseHooksFromJson(updatedHooksFile);
-
-          // All other hooks should still be present
-          for (const otherHook of otherHooks) {
-            const found = remainingHooks.find((h) => h.id === otherHook.id);
-            expect(
-              found,
-              `Hook ${otherHook.id} should still be present in updatedHooksFile`
-            ).toBeDefined();
-          }
-        }
-      ),
-      { numRuns: 100 }
-    );
-  });
-
-  it('should return original content and empty resolvedEntry when hookId is not found', () => {
-    fc.assert(
-      fc.property(
-        fc.array(openHookArb, { minLength: 1, maxLength: 5 }),
-        fc.uuid(),
-        fc.integer({ min: 1, max: 200 }),
-        (hooks, nonExistentId, chapterIndex) => {
-          // Ensure the nonExistentId is not in the hooks list
-          const uniqueHooks = hooks.filter((h) => h.id !== nonExistentId);
-          const hooksFileContent = buildHooksFileContent(uniqueHooks);
-
-          const { updatedHooksFile, resolvedEntry } = resolveHook(
-            hooksFileContent,
-            nonExistentId,
-            chapterIndex
-          );
-
-          // updatedHooksFile should be unchanged
-          expect(updatedHooksFile).toBe(hooksFileContent);
-
-          // resolvedEntry should be empty
-          expect(resolvedEntry).toBe('');
-        }
-      ),
-      { numRuns: 100 }
-    );
-  });
-});

+ 1 - 1
src/app/lib/chapter-prompts.ts

@@ -83,7 +83,7 @@ export const CONTINUITY_CHECK_ITEMS = [
   { id: 'setting_drift', label: '设定漂移', desc: '世界规则、背景设定是否前后一致', checkPrompt: '检查世界规则和背景设定是否前后一致。查看:时间线是否断裂、地理是否矛盾、规则是否有例外未解释、社会结构是否自洽。' },
   { id: 'character_distortion', label: '人物失真', desc: '人物性格、语言、行为是否偏离档案', checkPrompt: '对比人物档案与本章表现。检查:性格是否一致(或在合理弧光内变化)、语言特征是否保持、行为是否有动机支撑、情绪反应是否合理。' },
   { id: 'relationship_break', label: '关系断档', desc: '人物关系演变是否连贯可追踪', checkPrompt: '追踪人物之间的关系线。检查:关系变化是否有铺垫、前文建立的关系压力是否延续或合理转化、是否有人物突然对另一位人物态度剧变无解释。' },
-  { id: 'foreshadow_loss', label: '伏笔遗失', desc: '埋下的伏笔是否有回收计划或已失踪', checkPrompt: '核对伏笔账本。检查:已埋未收的伏笔是否在本章有推进(哪怕只是一个小触碰)、是否有伏笔已过最佳回收窗口、新埋的伏笔是否进入了账本。' },
+  // foreshadow_loss removed — foreshadow tracking now handled by event graph
   { id: 'plotline_neglect', label: '支线冷落', desc: '非主线的活跃度和推进状态', checkPrompt: '检查所有活跃支线。评估:每条支线距离上次推进过了多少章、是否有关键支线被遗忘、是否有支线本该在本章有进展但完全没有出现。' },
   { id: 'emotional_debt', label: '情绪债务', desc: '前文遗留的情绪承诺是否兑现', checkPrompt: '追踪情绪债务账本。检查:前文建立的"需要回应"的情感时刻是否被回应、是否有角色的情感状态在无解释的情况下突变、新产生的情感承诺是否被记录。' },
   { id: 'ai_patterns', label: 'AI 模式化', desc: '模板句式、假深度、空泛收束等 AI 特征', checkPrompt: '扫描 AI 模式化痕迹。检查:是否有"不仅……更……"等模板对比句式、"在这个充满……的世界里"等假深度开头、段落收束是否总是"这一切都表明……"式空泛总结、是否有抽象套话替代具体描写。' },

+ 2 - 5
src/app/lib/novel-types.ts

@@ -74,12 +74,9 @@ export type WorldLoreFile = WorldStateFile;
 // 叙事钩子
 export interface NarrativeHook {
   id: string; // UUID
-  type: 'suspense' | 'foreshadowing' | 'author_reader_gap' | 'reader_protagonist_gap';
+  type: 'suspense' | 'author_reader_gap' | 'reader_protagonist_gap';
   description: string;
   introducedInChapter: number;
-  status: 'open' | 'resolved';
-  resolvedInChapter?: number;
-  resolvedAt?: string;
 }
 
 // 章节摘要
@@ -112,7 +109,7 @@ export const DEFAULT_WORLD_STATE_FILES = [
   'protagonist_inventory.md',
   'character_relations.md',
   'world_notes.md',
-  'narrative_hooks.json',
+  'immersion-hooks.md',
   'writing_style.md',
 ] as const;
 

+ 7 - 80
src/app/lib/novel-utils.ts

@@ -225,8 +225,8 @@ export function getDefaultTemplate(fileName: string): string {
 (其他需要记录的世界细节)
 `;
 
-    case 'narrative_hooks.json':
-      return JSON.stringify({ version: 1, hooks: [] }, null, 2);
+    case 'immersion-hooks.md':
+      return `# 代入感系统\n\n(暂无内容)\n`;
 
     case 'writing_style.md':
       return `# 文风档案
@@ -838,81 +838,8 @@ export function parseHooksFromJson(json: string): NarrativeHook[] {
 }
 
 // ─────────────────────────────────────────────
-// 7. resolveHook
-/**
- * 将指定钩子标记为已解决状态(JSON格式):
- * - 将钩子的 status 设为 resolved
- * - 记录解决章节和时间戳
- * - 保留钩子在数组中(不移除)
- *
- * @param hooksJsonContent - narrative_hooks.json 的当前内容
- * @param hookId - 要解决的钩子 ID
- * @param chapterIndex - 解决该钩子的章节序号
- * @returns updatedHooksJson - 更新后的 JSON 内容
- */
-export function resolveHook(
-  hooksJsonContent: string,
-  hookId: string,
-  chapterIndex: number
-): { updatedHooksFile: string; resolvedEntry: string } {
-  const hooks = parseHooksFromJson(hooksJsonContent);
-  const hookIndex = hooks.findIndex((h) => h.id === hookId);
-
-  if (hookIndex === -1) {
-    return { updatedHooksFile: hooksJsonContent, resolvedEntry: '' };
-  }
-
-  const resolvedHook = hooks[hookIndex];
-  hooks[hookIndex] = {
-    ...resolvedHook,
-    status: 'resolved',
-    resolvedInChapter: chapterIndex,
-    resolvedAt: new Date().toISOString(),
-  };
-
-  const resolvedEntry = [
-    `## ${resolvedHook.id}`,
-    `- **类型**: ${resolvedHook.type}`,
-    `- **描述**: ${resolvedHook.description}`,
-    `- **埋入章节**: ${resolvedHook.introducedInChapter}`,
-    `- **解决章节**: ${chapterIndex}`,
-    `- **状态**: resolved`,
-  ].join('\n');
-
-  return {
-    updatedHooksFile: serializeHooksToJson(hooks),
-    resolvedEntry,
-  };
-}
-
-/**
- * 从 JSON 格式中删除指定钩子
- *
- * @param hooksJsonContent - narrative_hooks.json 的当前内容
- * @param hookId - 要删除的钩子 ID
- * @returns updatedHooksJson - 更新后的 JSON 内容
- */
-export function deleteHook(
-  hooksJsonContent: string,
-  hookId: string
-): string {
-  const hooks = parseHooksFromJson(hooksJsonContent);
-  const filteredHooks = hooks.filter((h) => h.id !== hookId);
-  return serializeHooksToJson(filteredHooks);
-}
-
-/**
- * 向 JSON 格式的钩子文件中添加新钩子
- *
- * @param hooksJsonContent - narrative_hooks.json 的当前内容(空字符串表示无文件)
- * @param newHooks - 要添加的新钩子数组
- * @returns updatedHooksJson - 更新后的 JSON 内容
- */
-export function addHooks(
-  hooksJsonContent: string,
-  newHooks: NarrativeHook[]
-): string {
-  const existingHooks = hooksJsonContent ? parseHooksFromJson(hooksJsonContent) : [];
-  const allHooks = [...existingHooks, ...newHooks];
-  return serializeHooksToJson(allHooks);
-}
+// 7. (removed) resolveHook / deleteHook / addHooks
+// Hook resolution tracking and JSON manipulation removed as part of
+// the "叙事钩子" → "代入感系统" refactoring.
+// Hooks are now stored per-chapter as Markdown in world_state/immersion-hooks/.
+// ─────────────────────────────────────────────