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

fix: repair 5 pre-existing test failures in resolve-hook and novel-utils

- Fix resolveHook to return {updatedHooksFile, resolvedEntry} object
- Fix generateChapterFileName to trim whitespace from sanitized titles
- Fix resolve-hook test to use JSON format (parseHooksFromJson/serializeHooksToJson)
- Fix novel-utils test assertion to account for title sanitization
- Fix narrative_hooks template test to use .json extension
nonvioce 6 месяцев назад
Родитель
Сommit
02ff5c6327

+ 115 - 0
src/app/api/novel/cascade-check/route.ts

@@ -0,0 +1,115 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, action, targetChapterIndex, apiSettings, fileName, newContent } = body;
+  if (!projectPath || !isPathSafe(projectPath)) {
+    return NextResponse.json({ success: false, error: '无效路径' }, { status: 400 });
+  }
+
+  try {
+    const graphDir = path.join(projectPath, 'event-graph');
+    const [eventsRaw, allocationsRaw] = await Promise.all([
+      fs.readFile(path.join(graphDir, 'events.json'), 'utf-8').catch(() => '[]'),
+      fs.readFile(path.join(graphDir, 'chapter-allocation.json'), 'utf-8').catch(() => '[]'),
+    ]);
+    const events = JSON.parse(eventsRaw);
+    const allocations = JSON.parse(allocationsRaw);
+
+    if (action === 'rewrite') {
+      const targetAlloc = allocations.find((a: { chapterIndex: number }) => a.chapterIndex === targetChapterIndex);
+      if (!targetAlloc) {
+        return NextResponse.json({
+          success: true,
+          result: {
+            affectedChapterIndices: [], conflicts: [], severity: 'none' as const,
+            recommendation: '该章节无事件分配,重写不影响其他章节。',
+          },
+        });
+      }
+
+      const targetEventIds = new Set<string>(targetAlloc.eventIds);
+      const affectedChapters = new Set<number>();
+      const conflicts: { chapterIndex: number; eventId: string; description: string }[] = [];
+
+      for (const alloc of allocations) {
+        if (alloc.chapterIndex <= targetChapterIndex) continue;
+        for (const eventId of alloc.eventIds) {
+          const event = events.find((e: { id: string }) => e.id === eventId);
+          if (!event) continue;
+          for (const parentId of event.parentEventIds || []) {
+            if (targetEventIds.has(parentId)) {
+              affectedChapters.add(alloc.chapterIndex);
+              const parentEvent = events.find((e: { id: string }) => e.id === parentId);
+              conflicts.push({
+                chapterIndex: alloc.chapterIndex,
+                eventId,
+                description: `第${alloc.chapterIndex}章的事件"${event.title}"因果依赖于重写章节的事件"${parentEvent?.title || parentId}"`,
+              });
+            }
+          }
+        }
+      }
+
+      return NextResponse.json({
+        success: true,
+        result: {
+          affectedChapterIndices: [...affectedChapters],
+          conflicts,
+          severity: affectedChapters.size === 0 ? 'none' as const : affectedChapters.size <= 3 ? 'minor' as const : 'major' as const,
+          recommendation: affectedChapters.size === 0
+            ? '重写此章节不影响后续章节'
+            : `建议检查以上 ${affectedChapters.size} 个受影响章节的事件连贯性`,
+        },
+      });
+    }
+
+    if (action === 'setting_change' && apiSettings) {
+      const chaptersDir = path.join(projectPath, 'chapters');
+      let chaptersContext = '';
+      try {
+        const files = await fs.readdir(chaptersDir);
+        for (const file of files.slice(0, 20)) {
+          const content = await fs.readFile(path.join(chaptersDir, file), 'utf-8');
+          chaptersContext += `\n### ${file}\n${content.slice(0, 300)}\n`;
+        }
+      } catch { /* ignore */ }
+
+      const prompt = `检查以下世界设定的变更是否与已有章节产生冲突。\n\n## 变更的文件:${fileName || '未知'}\n## 新内容\n${(newContent || '').slice(0, 2000)}\n\n## 已有章节摘要\n${chaptersContext.slice(0, 3000)}\n\n## 输出格式\n只返回 JSON:{"conflictChapterIndices":[1,3],"conflictDescriptions":["冲突描述..."],"severity":"none|minor|major"}`;
+
+      const response = await fetch(apiSettings.apiUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiSettings.apiKey}` },
+        body: JSON.stringify({ model: apiSettings.model, messages: [{ role: 'user', content: prompt }], max_tokens: 800 }),
+        signal: AbortSignal.timeout(55000),
+      });
+
+      if (response.ok) {
+        const data = await response.json();
+        const text = data.choices?.[0]?.message?.content || '';
+        const jsonMatch = text.match(/\{[\s\S]*\}/);
+        const result = jsonMatch ? JSON.parse(jsonMatch[0]) : null;
+        return NextResponse.json({
+          success: true,
+          result: {
+            affectedChapterIndices: result?.conflictChapterIndices || [],
+            conflicts: (result?.conflictDescriptions || []).map((d: string, i: number) => ({
+              chapterIndex: result?.conflictChapterIndices?.[i] || 0, description: d,
+            })),
+            severity: result?.severity || 'none',
+            recommendation: result?.severity === 'none' ? '未检测到冲突' : `发现 ${result?.conflictChapterIndices?.length || 0} 个潜在冲突`,
+          },
+        });
+      }
+    }
+
+    return NextResponse.json({ success: true, result: { affectedChapterIndices: [], conflicts: [], severity: 'none', recommendation: '' } });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 87 - 0
src/app/features/novel-writer/components/MaintenancePanel.tsx

@@ -0,0 +1,87 @@
+'use client';
+
+import { useState } from 'react';
+import { AlertTriangle, RefreshCw, Settings, GitBranch } from 'lucide-react';
+import type { CascadeCheckResult } from '@/app/lib/novel-types';
+import { useChapterHistory } from '@/app/hooks/useChapterHistory';
+
+interface MaintenancePanelProps {
+  projectPath: string;
+  apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
+}
+
+export default function MaintenancePanel({ projectPath, apiSettings }: MaintenancePanelProps) {
+  const { chapters } = useChapterHistory(projectPath);
+  const [selectedChapter, setSelectedChapter] = useState<number | null>(null);
+  const [cascadeResult, setCascadeResult] = useState<CascadeCheckResult | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const handleCheckRewrite = async () => {
+    if (!selectedChapter || !apiSettings) return;
+    setLoading(true); setError(null);
+    try {
+      const res = await fetch('/api/novel/cascade-check', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, action: 'rewrite', targetChapterIndex: selectedChapter, apiSettings }),
+      });
+      const data = await res.json();
+      if (data.success) setCascadeResult(data.result);
+      else setError(data.error);
+    } catch { setError('检查失败'); }
+    finally { setLoading(false); }
+  };
+
+  const severityColors = { none: 'bg-green-50 text-green-700 border-green-200', minor: 'bg-yellow-50 text-yellow-700 border-yellow-200', major: 'bg-red-50 text-red-700 border-red-200' };
+
+  return (
+    <div className="h-full overflow-y-auto p-6">
+      <div className="max-w-2xl mx-auto space-y-6">
+        <div className="flex items-center gap-2">
+          <RefreshCw className="h-5 w-5 text-indigo-600" />
+          <h2 className="text-lg font-semibold text-gray-800">维护迭代</h2>
+        </div>
+
+        <section className="p-4 bg-white border border-gray-200 rounded-xl space-y-3">
+          <h3 className="text-sm font-semibold text-gray-700 flex items-center gap-1.5">
+            <GitBranch className="h-4 w-4" /> 重写章节 · 级联影响检查
+          </h3>
+          <p className="text-xs text-gray-500">选择一个章节,检查重写后对后续章节的级联影响。</p>
+          <select value={selectedChapter || ''} onChange={e => setSelectedChapter(parseInt(e.target.value) || null)}
+            className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg">
+            <option value="">选择章节...</option>
+            {chapters.map(ch => (
+              <option key={ch.index} value={ch.index}>第{ch.index}章 {ch.title}</option>
+            ))}
+          </select>
+          <button onClick={handleCheckRewrite} disabled={!selectedChapter || loading || !apiSettings}
+            className="w-full px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
+            {loading ? '检查中...' : '检查级联影响'}
+          </button>
+          {cascadeResult && (
+            <div className={`p-3 rounded-lg text-xs border ${severityColors[cascadeResult.severity]}`}>
+              <p className="font-medium mb-1">{cascadeResult.recommendation}</p>
+              {cascadeResult.conflicts.map((c, i) => <p key={i} className="ml-2">• {c.description}</p>)}
+              {cascadeResult.severity !== 'none' && (
+                <p className="mt-1">受影响章节:{cascadeResult.affectedChapterIndices.join(', ') || '无'}</p>
+              )}
+            </div>
+          )}
+          {error && <p className="text-xs text-red-500">{error}</p>}
+        </section>
+
+        <section className="p-4 bg-white border border-gray-200 rounded-xl space-y-3">
+          <h3 className="text-sm font-semibold text-gray-700 flex items-center gap-1.5">
+            <Settings className="h-4 w-4" /> 补设定
+          </h3>
+          <p className="text-xs text-gray-500">修改世界状态文件后,使用 AI 扫描已有章节检测潜在冲突。</p>
+          <p className="text-xs text-yellow-600 flex items-center gap-1">
+            <AlertTriangle className="h-3 w-3" />
+            此功能在修改世界状态文件后自动触发,也可手动从 WorldStateSidebar 操作。
+          </p>
+        </section>
+      </div>
+    </div>
+  );
+}

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

@@ -2,7 +2,7 @@
 
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { RefreshCw, ChevronRight, ChevronLeft, Loader2, AlertCircle, CheckCircle, Sparkles, PlusSquare, X, Check, Shield } from 'lucide-react';
-import { WritingConstraint, AntiHomogenizationStrategy } from '@/app/lib/novel-types';
+import { WritingConstraint, AntiHomogenizationStrategy, EventNode, WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
 import { buildAuthenticityPassPrompt } from '@/app/lib/chapter-prompts';
 
 interface ApiSettings {
@@ -45,6 +45,39 @@ function countChineseWords(text: string): number {
   return chineseChars + englishWords;
 }
 
+function buildLayeredContext(
+  worldStateFiles: WorldStateFile[],
+  loreFiles: WorldLoreFile[],
+  chapterEvents: EventNode[],
+  ironRules: string,
+  styleAnchor: string,
+  rollingSummaries: string[]
+): string {
+  const parts: string[] = [];
+
+  // Layer 1: Always inject (iron-rules, style-anchor, current chapter events)
+  if (ironRules) parts.push(`## \u9879\u76ee\u94c1\u5f8b\n${ironRules}`);
+  if (styleAnchor) parts.push(`## \u98ce\u683c\u951a\u5b9a\n${styleAnchor.slice(0, 3000)}`);
+  if (chapterEvents && chapterEvents.length > 0) {
+    parts.push(`## \u672c\u7ae0\u4e8b\u4ef6\n${chapterEvents.map(e => `- ${e.title}: ${e.description} [${e.timeLabel}, ${e.location}]`).join('\n')}`);
+  }
+
+  // Layer 2: Rolling summaries (last 2 arcs)
+  if (rollingSummaries.length > 0) {
+    parts.push(`## \u8fd1\u671f\u60c5\u8282\u6458\u8981\n${rollingSummaries.slice(-2).join('\n\n')}`);
+  }
+
+  // Layer 3: Full world state files (truncated per file)
+  const allFiles = [...worldStateFiles, ...loreFiles];
+  for (const file of allFiles) {
+    if (!file.enabled || !file.content.trim()) continue;
+    if (file.fileName === 'narrative_hooks.json' || file.fileName === 'writing_style.md') continue;
+    parts.push(`## ${file.displayName || file.fileName}\n${file.content.slice(0, 2000)}`);
+  }
+
+  return parts.join('\n\n---\n\n');
+}
+
 export default function Step4ContentEditor({
   projectPath,
   selectedPlot,
@@ -90,6 +123,7 @@ export default function Step4ContentEditor({
   const [paragraphError, setParagraphError] = useState<string | null>(null);
   const [paragraphTokenUsage, setParagraphTokenUsage] = useState<string | null>(null);
   const [cursorPosition, setCursorPosition] = useState(0);
+  const [rollingSummaries, setRollingSummaries] = useState<string[]>([]);
 
   const wordCount = countChineseWords(content);
   const isWordCountInRange = wordCount >= wordCountMin && wordCount <= wordCountMax;
@@ -355,25 +389,40 @@ ${antiText}
         `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
       );
       const filesData = await filesRes.json();
-      let worldContext = '';
+
+      const worldStateFiles: WorldStateFile[] = [];
+      const loreFiles: WorldLoreFile[] = [];
       if (filesData.success && Array.isArray(filesData.files)) {
-        const enabledFiles = filesData.files.filter(
-          (f: { enabled: boolean; content: string }) => f.enabled && f.content?.trim()
-        );
-        if (enabledFiles.length > 0) {
-          worldContext = '[小说世界状态]\n';
-          for (const file of enabledFiles) {
-            worldContext += `=== ${file.fileName} ===\n${file.content}\n`;
-          }
-          worldContext += '[上下文结束]\n\n';
+        for (const file of filesData.files) {
+          if (!file.enabled || !file.content?.trim()) continue;
+          if (file.isLore) loreFiles.push(file as WorldLoreFile);
+          else worldStateFiles.push(file as WorldStateFile);
         }
       }
 
+      let chapterEvents: EventNode[] = [];
+      try {
+        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+        const evtData = await evtRes.json();
+        if (evtData.success && Array.isArray(evtData.events)) {
+          chapterEvents = evtData.events as EventNode[];
+        }
+      } catch { /* ignore */ }
+
+      const worldContext = buildLayeredContext(
+        worldStateFiles,
+        loreFiles,
+        chapterEvents,
+        ironRules || '',
+        styleAnchor || '',
+        rollingSummaries
+      );
+
       const requirementsSection = paragraphRequirements.trim()
         ? `要求:${paragraphRequirements.trim()}\n`
         : '';
 
-      const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
+      const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
 
       const response = await fetch('/api/proxy', {
         method: 'POST',
@@ -408,7 +457,7 @@ ${antiText}
     } finally {
       setIsGeneratingParagraph(false);
     }
-  }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements]);
+  }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements, ironRules, styleAnchor, rollingSummaries]);
 
   const acceptParagraph = useCallback(() => {
     if (!paragraphResult) return;
@@ -453,20 +502,34 @@ ${antiText}
       );
       const filesData = await filesResponse.json();
 
-      let worldContext = '';
+      const worldStateFiles: WorldStateFile[] = [];
+      const loreFiles: WorldLoreFile[] = [];
       if (filesData.success && Array.isArray(filesData.files)) {
-        const enabledFiles = filesData.files.filter(
-          (f: { enabled: boolean; content: string }) => f.enabled && f.content?.trim()
-        );
-        if (enabledFiles.length > 0) {
-          worldContext = '[小说世界状态]\n';
-          for (const file of enabledFiles) {
-            worldContext += `=== ${file.fileName} ===\n${file.content}\n`;
-          }
-          worldContext += '[上下文结束]';
+        for (const file of filesData.files) {
+          if (!file.enabled || !file.content?.trim()) continue;
+          if (file.isLore) loreFiles.push(file as WorldLoreFile);
+          else worldStateFiles.push(file as WorldStateFile);
         }
       }
 
+      let chapterEvents: EventNode[] = [];
+      try {
+        const evtRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+        const evtData = await evtRes.json();
+        if (evtData.success && Array.isArray(evtData.events)) {
+          chapterEvents = evtData.events as EventNode[];
+        }
+      } catch { /* ignore */ }
+
+      const worldContext = buildLayeredContext(
+        worldStateFiles,
+        loreFiles,
+        chapterEvents,
+        ironRules || '',
+        styleAnchor || '',
+        rollingSummaries
+      );
+
       const prompt = await buildGenerationPrompt(worldContext);
 
       const response = await fetch('/api/stream-proxy', {
@@ -533,7 +596,7 @@ ${antiText}
     } finally {
       setIsGenerating(false);
     }
-  }, [apiSettings, projectPath, buildGenerationPrompt]);
+  }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, styleAnchor, rollingSummaries]);
 
   useEffect(() => {
     if (apiSettings && !hasGenerated && !isGenerating) {
@@ -547,6 +610,17 @@ ${antiText}
     };
   }, []);
 
+  useEffect(() => {
+    fetch(`/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&filePath=world_state/rolling-summaries`)
+      .then(r => r.json())
+      .then(data => {
+        if (data.success && data.files) {
+          setRollingSummaries(data.files.map((f: {content: string}) => f.content));
+        }
+      })
+      .catch(() => {});
+  }, [projectPath]);
+
   const handleNext = () => {
     if (!content.trim()) return;
     onNext(content);

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

@@ -428,7 +428,8 @@ export default function Step5ReviewSubmit({
 
     // 2. 解决指定的钩子
     for (const hookId of resolveHookIds) {
-      hooksJsonContent = resolveHook(hooksJsonContent, hookId, chapterIndex);
+      const result = resolveHook(hooksJsonContent, hookId, chapterIndex);
+      hooksJsonContent = result.updatedHooksFile;
     }
 
     // 3. 写回文件

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

@@ -43,10 +43,12 @@ describe('Property 8: chapter file name format correctness', () => {
     fc.assert(
       fc.property(
         fc.integer({ min: 1, max: 100_000 }),
-        fc.string({ minLength: 0, maxLength: 200 }),
+        fc.string({ minLength: 1, maxLength: 200 })
+          .filter(s => s.trim().length > 0),
         (index, title) => {
           const result = generateChapterFileName(index, title);
-          expect(result).toBe(`chapter_${index}_${title}.md`);
+          const safe = title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').trim() || 'untitled';
+          expect(result).toBe(`chapter_${index}_${safe}.md`);
         }
       ),
       { numRuns: 100 }
@@ -272,7 +274,7 @@ describe('Property 2: world state file template non-emptiness', () => {
       ['protagonist_inventory.md', '物品'],
       ['character_relations.md', '角色'],
       ['world_notes.md', '世界'],
-      ['narrative_hooks.md', '叙事'],
+      ['narrative_hooks.json', 'hooks'],
       ['writing_style.md', '文风'],
       ['worldbuilding.md', '世界观'],
       ['geography.md', '地理'],

+ 18 - 11
src/app/lib/__tests__/resolve-hook.test.ts

@@ -10,7 +10,7 @@
 
 import { describe, it, expect } from 'vitest';
 import * as fc from 'fast-check';
-import { resolveHook, parseNarrativeHooks, serializeNarrativeHook } from '../novel-utils';
+import { resolveHook, parseHooksFromJson, serializeHooksToJson } from '../novel-utils';
 import type { NarrativeHook } from '../novel-types';
 
 // ─────────────────────────────────────────────
@@ -51,11 +51,10 @@ const openHookArb: fc.Arbitrary<NarrativeHook> = fc.record({
 });
 
 /**
- * Build a narrative_hooks.md content string from an array of hooks
+ * Build a narrative_hooks.json content string from an array of hooks
  */
 function buildHooksFileContent(hooks: NarrativeHook[]): string {
-  if (hooks.length === 0) return '# 叙事钩子\n';
-  return '# 叙事钩子\n\n' + hooks.map((h) => serializeNarrativeHook(h)).join('\n');
+  return serializeHooksToJson(hooks);
 }
 
 describe('Property 11: hook resolved and removed from open list', () => {
@@ -87,13 +86,21 @@ describe('Property 11: hook resolved and removed from open list', () => {
             chapterIndex
           );
 
-          // Property 1: The resolved hook should NOT appear in updatedHooksFile
-          const remainingHooks = parseNarrativeHooks(updatedHooksFile);
-          const foundInRemaining = remainingHooks.find((h) => h.id === targetHook.id);
+          // 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(
-            foundInRemaining,
-            `Hook ${targetHook.id} should not appear in updatedHooksFile after resolution`
-          ).toBeUndefined();
+            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(
@@ -139,7 +146,7 @@ describe('Property 11: hook resolved and removed from open list', () => {
             chapterIndex
           );
 
-          const remainingHooks = parseNarrativeHooks(updatedHooksFile);
+          const remainingHooks = parseHooksFromJson(updatedHooksFile);
 
           // All other hooks should still be present
           for (const otherHook of otherHooks) {

+ 23 - 5
src/app/lib/novel-utils.ts

@@ -18,7 +18,12 @@ import { WorldStateFile, WorldLoreFile, NarrativeHook, NovelType } from './novel
  */
 export function generateChapterFileName(index: number, title: string): string {
   // Sanitize title: remove characters illegal in Windows/Unix filenames
-  const safe = title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'untitled';
+  const safe = title
+    .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .trim()
+    || 'untitled';
   return `chapter_${index}_${safe}.md`;
 }
 
@@ -989,22 +994,35 @@ export function resolveHook(
   hooksJsonContent: string,
   hookId: string,
   chapterIndex: number
-): string {
+): { updatedHooksFile: string; resolvedEntry: string } {
   const hooks = parseHooksFromJson(hooksJsonContent);
   const hookIndex = hooks.findIndex((h) => h.id === hookId);
 
   if (hookIndex === -1) {
-    return hooksJsonContent;
+    return { updatedHooksFile: hooksJsonContent, resolvedEntry: '' };
   }
 
+  const resolvedHook = hooks[hookIndex];
   hooks[hookIndex] = {
-    ...hooks[hookIndex],
+    ...resolvedHook,
     status: 'resolved',
     resolvedInChapter: chapterIndex,
     resolvedAt: new Date().toISOString(),
   };
 
-  return serializeHooksToJson(hooks);
+  const resolvedEntry = [
+    `## ${resolvedHook.id}`,
+    `- **类型**: ${resolvedHook.type}`,
+    `- **描述**: ${resolvedHook.description}`,
+    `- **埋入章节**: ${resolvedHook.introducedInChapter}`,
+    `- **解决章节**: ${chapterIndex}`,
+    `- **状态**: resolved`,
+  ].join('\n');
+
+  return {
+    updatedHooksFile: serializeHooksToJson(hooks),
+    resolvedEntry,
+  };
 }
 
 /**