Преглед изворни кода

refactor: remove WorldStateSidebar, move hooks to Phase 1, add iron-rules to WorldEditor, add external-open button, fix InitWizard

- Remove generateStyleAnchor and includeNegativeSamples checkboxes from InitWizard Step 5
- Add iron-rules.md as editable file in WorldEditor file tree sidebar
- Add "外部打开" (external open) button next to "AI 扩展" in WorldEditor toolbar
- Remove WorldStateSidebar from Phase 2 layout (NovelContextManager)
- Extract narrative hook management into NarrativeHookPanel component
- Add "叙事钩子" third tab to Phase 1 (alongside 世界观编辑, 事件图)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce пре 6 месеци
родитељ
комит
8c6e79b535

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

@@ -586,8 +586,6 @@ function Step5StyleAndGenerate({
   styleIntensity, setStyleIntensity, paragraphMode, setParagraphMode,
   styleIntensity, setStyleIntensity, paragraphMode, setParagraphMode,
   useChapterTitles, setUseChapterTitles,
   useChapterTitles, setUseChapterTitles,
   generateIronRules, setGenerateIronRules,
   generateIronRules, setGenerateIronRules,
-  generateStyleAnchor, setGenerateStyleAnchor,
-  includeNegativeSamples, setIncludeNegativeSamples,
   onBack, onComplete,
   onBack, onComplete,
 }: {
 }: {
   projectPath: string; novelType: NovelType; worldDescription: string;
   projectPath: string; novelType: NovelType; worldDescription: string;
@@ -600,8 +598,6 @@ function Step5StyleAndGenerate({
   paragraphMode: 'web-serial-natural' | 'long-paragraph'; setParagraphMode: (v: 'web-serial-natural' | 'long-paragraph') => void;
   paragraphMode: 'web-serial-natural' | 'long-paragraph'; setParagraphMode: (v: 'web-serial-natural' | 'long-paragraph') => void;
   useChapterTitles: boolean; setUseChapterTitles: (v: boolean) => void;
   useChapterTitles: boolean; setUseChapterTitles: (v: boolean) => void;
   generateIronRules: boolean; setGenerateIronRules: (v: boolean) => void;
   generateIronRules: boolean; setGenerateIronRules: (v: boolean) => void;
-  generateStyleAnchor: boolean; setGenerateStyleAnchor: (v: boolean) => void;
-  includeNegativeSamples: boolean; setIncludeNegativeSamples: (v: boolean) => void;
   onBack: () => void; onComplete: () => void;
   onBack: () => void; onComplete: () => void;
 }) {
 }) {
   const [isRunning, setIsRunning] = useState(false);
   const [isRunning, setIsRunning] = useState(false);
@@ -636,7 +632,6 @@ function Step5StyleAndGenerate({
           writingStyleDescription: enrichedDescription || undefined,
           writingStyleDescription: enrichedDescription || undefined,
           disabledFiles,
           disabledFiles,
           generateIronRules,
           generateIronRules,
-          generateStyleAnchor,
           styleInfo: {
           styleInfo: {
             primaryStyle: primaryStyleId,
             primaryStyle: primaryStyleId,
             supportStyle: supportStyleId,
             supportStyle: supportStyleId,
@@ -666,7 +661,7 @@ function Step5StyleAndGenerate({
     } finally {
     } finally {
       setIsRunning(false);
       setIsRunning(false);
     }
     }
-  }, [projectPath, novelType, worldDescription, apiSettings, styleDescription, disabledFiles, interviewData, primaryStyleId, supportStyleId, styleIntensity, paragraphMode, useChapterTitles, generateIronRules, generateStyleAnchor]);
+  }, [projectPath, novelType, worldDescription, apiSettings, styleDescription, disabledFiles, interviewData, primaryStyleId, supportStyleId, styleIntensity, paragraphMode, useChapterTitles, generateIronRules]);
 
 
   return (
   return (
     <div>
     <div>
@@ -750,26 +745,6 @@ function Step5StyleAndGenerate({
           />
           />
           自动生成项目铁律 (iron-rules.md)
           自动生成项目铁律 (iron-rules.md)
         </label>
         </label>
-        <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
-          <input
-            type="checkbox"
-            checked={generateStyleAnchor}
-            onChange={(e) => setGenerateStyleAnchor(e.target.checked)}
-            className="w-4 h-4 text-indigo-600 rounded"
-          />
-          自动生成风格锚定(附入 writing_style.md)
-        </label>
-        {generateStyleAnchor && (
-          <label className="flex items-center gap-2 text-sm text-gray-600 ml-6 cursor-pointer">
-            <input
-              type="checkbox"
-              checked={includeNegativeSamples}
-              onChange={(e) => setIncludeNegativeSamples(e.target.checked)}
-              className="w-3.5 h-3.5 text-indigo-500 rounded"
-            />
-            包含负面示范样本
-          </label>
-        )}
       </div>
       </div>
 
 
       {globalError && <p className="text-sm text-red-500 mb-4">{globalError}</p>}
       {globalError && <p className="text-sm text-red-500 mb-4">{globalError}</p>}
@@ -933,8 +908,6 @@ export default function InitWizard({ projectPath, novelType: initialNovelType, a
   const [paragraphMode, setParagraphMode] = useState<'web-serial-natural' | 'long-paragraph'>('web-serial-natural');
   const [paragraphMode, setParagraphMode] = useState<'web-serial-natural' | 'long-paragraph'>('web-serial-natural');
   const [useChapterTitles, setUseChapterTitles] = useState(true);
   const [useChapterTitles, setUseChapterTitles] = useState(true);
   const [generateIronRules, setGenerateIronRules] = useState(true);
   const [generateIronRules, setGenerateIronRules] = useState(true);
-  const [generateStyleAnchor, setGenerateStyleAnchor] = useState(true);
-  const [includeNegativeSamples, setIncludeNegativeSamples] = useState(true);
 
 
   const interviewData: InterviewData = {
   const interviewData: InterviewData = {
     protagonist, personality, coreCast,
     protagonist, personality, coreCast,
@@ -997,8 +970,6 @@ export default function InitWizard({ projectPath, novelType: initialNovelType, a
             paragraphMode={paragraphMode} setParagraphMode={setParagraphMode}
             paragraphMode={paragraphMode} setParagraphMode={setParagraphMode}
             useChapterTitles={useChapterTitles} setUseChapterTitles={setUseChapterTitles}
             useChapterTitles={useChapterTitles} setUseChapterTitles={setUseChapterTitles}
             generateIronRules={generateIronRules} setGenerateIronRules={setGenerateIronRules}
             generateIronRules={generateIronRules} setGenerateIronRules={setGenerateIronRules}
-            generateStyleAnchor={generateStyleAnchor} setGenerateStyleAnchor={setGenerateStyleAnchor}
-            includeNegativeSamples={includeNegativeSamples} setIncludeNegativeSamples={setIncludeNegativeSamples}
             onBack={() => setStep(4)} onComplete={() => onComplete(interviewData)} />
             onBack={() => setStep(4)} onComplete={() => onComplete(interviewData)} />
         )}
         )}
       </div>
       </div>

+ 270 - 0
src/app/features/novel-writer/components/NarrativeHookPanel.tsx

@@ -0,0 +1,270 @@
+'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';
+
+// ─────────────────────────────────────────────
+// Types
+// ─────────────────────────────────────────────
+
+interface NarrativeHookPanelProps {
+  projectPath: string;
+  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>
+  );
+}
+
+// ─────────────────────────────────────────────
+// Main component: NarrativeHookPanel
+// ─────────────────────────────────────────────
+
+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');
+
+  // ── Handlers ──
+
+  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]
+  );
+
+  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>
+            <p className="text-xs text-gray-500 mt-0.5">
+              管理故事中的悬念、伏笔和信息差,追踪哪些钩子已回收
+            </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>
+          )}
+        </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"
+              />
+
+              {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-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>
+
+        {/* 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>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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

@@ -7,9 +7,9 @@ import EventGraphEditor from '@/app/features/novel-writer/components/EventGraphE
 import WorldEditor from '@/app/features/novel-writer/components/WorldEditor';
 import WorldEditor from '@/app/features/novel-writer/components/WorldEditor';
 import { ApiProvider } from '@/app/components/ApiSettings';
 import { ApiProvider } from '@/app/components/ApiSettings';
 import ApiSettings from '@/app/components/ApiSettings';
 import ApiSettings from '@/app/components/ApiSettings';
-import WorldStateSidebar from '@/app/features/novel-writer/components/WorldStateSidebar';
 import ChapterDraftPipeline from '@/app/features/novel-writer/components/ChapterDraftPipeline';
 import ChapterDraftPipeline from '@/app/features/novel-writer/components/ChapterDraftPipeline';
 import ChapterHistoryList from '@/app/features/novel-writer/components/ChapterHistoryList';
 import ChapterHistoryList from '@/app/features/novel-writer/components/ChapterHistoryList';
+import NarrativeHookPanel from '@/app/features/novel-writer/components/NarrativeHookPanel';
 import WritingConstraintManager from '@/app/features/novel-writer/components/WritingConstraintManager';
 import WritingConstraintManager from '@/app/features/novel-writer/components/WritingConstraintManager';
 import AntiHomogenizationPanel from '@/app/features/novel-writer/components/AntiHomogenizationPanel';
 import AntiHomogenizationPanel from '@/app/features/novel-writer/components/AntiHomogenizationPanel';
 import MaintenancePanel from '@/app/features/novel-writer/components/MaintenancePanel';
 import MaintenancePanel from '@/app/features/novel-writer/components/MaintenancePanel';
@@ -114,7 +114,7 @@ export default function NovelContextManager({
 
 
   // ── Phase 1 state ──
   // ── Phase 1 state ──
   const [currentPhase, setCurrentPhase] = useState<NovelPhase>('phase2');
   const [currentPhase, setCurrentPhase] = useState<NovelPhase>('phase2');
-  const [phase1Tab, setPhase1Tab] = useState<'world' | 'events'>('world');
+  const [phase1Tab, setPhase1Tab] = useState<'world' | 'events' | 'hooks'>('world');
 
 
   // ── Hooks ──
   // ── Hooks ──
   const { worldStateFiles, loreFiles } = useWorldState(projectPath);
   const { worldStateFiles, loreFiles } = useWorldState(projectPath);
@@ -315,15 +315,7 @@ export default function NovelContextManager({
   // ── Main layout ──
   // ── Main layout ──
   return (
   return (
     <div className="flex h-screen bg-gray-50 overflow-hidden">
     <div className="flex h-screen bg-gray-50 overflow-hidden">
-      {/* Left sidebar: World State */}
-      <WorldStateSidebar 
-        projectPath={projectPath} 
-        latestChapterIndex={chapters.length}
-        onFileEdit={handleFileEdit}
-        onWritingStyleClick={handleWritingStyleClick}
-      />
-
-      {/* Right main area */}
+      {/* Main area */}
       <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
       <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
         {/* Top toolbar */}
         {/* Top toolbar */}
         <header className="flex items-center justify-between px-5 py-3 bg-white border-b border-gray-200 shrink-0">
         <header className="flex items-center justify-between px-5 py-3 bg-white border-b border-gray-200 shrink-0">
@@ -419,6 +411,7 @@ export default function NovelContextManager({
               {[
               {[
                 { id: 'world' as const, label: '世界观编辑' },
                 { id: 'world' as const, label: '世界观编辑' },
                 { id: 'events' as const, label: '事件图' },
                 { id: 'events' as const, label: '事件图' },
+                { id: 'hooks' as const, label: '叙事钩子' },
               ].map(tab => (
               ].map(tab => (
                 <button key={tab.id} onClick={() => setPhase1Tab(tab.id)}
                 <button key={tab.id} onClick={() => setPhase1Tab(tab.id)}
                   className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
                   className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
@@ -434,6 +427,11 @@ export default function NovelContextManager({
                 apiSettings={apiSettings}
                 apiSettings={apiSettings}
                 initialFileName={editingFile?.file.fileName}
                 initialFileName={editingFile?.file.fileName}
               />
               />
+            ) : phase1Tab === 'hooks' ? (
+              <NarrativeHookPanel
+                projectPath={projectPath}
+                latestChapterIndex={chapters.length}
+              />
             ) : (
             ) : (
               <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
               <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
             )}
             )}

+ 100 - 14
src/app/features/novel-writer/components/WorldEditor.tsx

@@ -14,10 +14,17 @@ interface WorldEditorProps {
 export default function WorldEditor({ projectPath, apiSettings, initialFileName }: WorldEditorProps) {
 export default function WorldEditor({ projectPath, apiSettings, initialFileName }: WorldEditorProps) {
   const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
   const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
   const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
   const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
+  const [ironRulesFile, setIronRulesFile] = useState<WorldStateFile | null>(null);
 
 
   // Auto-select the file navigated from sidebar
   // Auto-select the file navigated from sidebar
   useEffect(() => {
   useEffect(() => {
     if (!initialFileName) return;
     if (!initialFileName) return;
+    // Check if it's iron-rules.md first
+    if (initialFileName === 'iron-rules.md' && ironRulesFile) {
+      setSelectedFile(ironRulesFile);
+      setEditContent(ironRulesFile.content);
+      return;
+    }
     const allFiles = [
     const allFiles = [
       ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
       ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
       ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
       ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
@@ -27,12 +34,34 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
       setSelectedFile(target);
       setSelectedFile(target);
       setEditContent(target.content);
       setEditContent(target.content);
     }
     }
-  }, [initialFileName, worldStateFiles, loreFiles]);
+  }, [initialFileName, worldStateFiles, loreFiles, ironRulesFile]);
+  // Load iron-rules.md from project root
+  useEffect(() => {
+    if (!projectPath) return;
+    fetch('/api/novel/files', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ action: 'read', projectPath, filePath: 'iron-rules.md' }),
+    }).then(r => r.json()).then(data => {
+      if (data.success) {
+        setIronRulesFile({
+          fileName: 'iron-rules.md',
+          displayName: '项目铁律',
+          content: data.content as string,
+          lastUpdatedAt: data.lastUpdatedAt || new Date().toISOString(),
+          enabled: true,
+          isLore: false,
+        });
+      }
+    }).catch(() => {});
+  }, [projectPath]);
+
   const [editContent, setEditContent] = useState('');
   const [editContent, setEditContent] = useState('');
   const [isAiExpanding, setIsAiExpanding] = useState(false);
   const [isAiExpanding, setIsAiExpanding] = useState(false);
   const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
   const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
 
 
   const allFiles = [
   const allFiles = [
+    ...(ironRulesFile ? [{ ...ironRulesFile, category: 'project' as const }] : []),
     ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
     ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
     ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
     ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
   ];
   ];
@@ -43,19 +72,35 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
     const timer = setTimeout(async () => {
     const timer = setTimeout(async () => {
       setSaveStatus('saving');
       setSaveStatus('saving');
       try {
       try {
-        await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
+        if (selectedFile.fileName === 'iron-rules.md') {
+          await fetch('/api/novel/files', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ action: 'write', projectPath, filePath: 'iron-rules.md', content: editContent }),
+          });
+        } else {
+          await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
+        }
         setSaveStatus('saved');
         setSaveStatus('saved');
         setTimeout(() => setSaveStatus('idle'), 2000);
         setTimeout(() => setSaveStatus('idle'), 2000);
       } catch { setSaveStatus('idle'); }
       } catch { setSaveStatus('idle'); }
     }, 1500);
     }, 1500);
     return () => clearTimeout(timer);
     return () => clearTimeout(timer);
-  }, [editContent, selectedFile, updateFile]);
+  }, [editContent, selectedFile, updateFile, projectPath]);
 
 
   const handleSelectFile = async (file: WorldStateFile) => {
   const handleSelectFile = async (file: WorldStateFile) => {
     // Force-save current file before switching
     // Force-save current file before switching
     if (selectedFile && editContent !== selectedFile.content && editContent.trim()) {
     if (selectedFile && editContent !== selectedFile.content && editContent.trim()) {
       try {
       try {
-        await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
+        if (selectedFile.fileName === 'iron-rules.md') {
+          await fetch('/api/novel/files', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ action: 'write', projectPath, filePath: 'iron-rules.md', content: editContent }),
+          });
+        } else {
+          await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
+        }
       } catch { /* ignore, proceed with switch */ }
       } catch { /* ignore, proceed with switch */ }
     }
     }
     setSelectedFile(file);
     setSelectedFile(file);
@@ -111,13 +156,30 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
     <div className="flex h-full bg-white">
     <div className="flex h-full bg-white">
       {/* File tree sidebar */}
       {/* File tree sidebar */}
       <div className="w-56 border-r border-gray-200 overflow-y-auto p-2 shrink-0">
       <div className="w-56 border-r border-gray-200 overflow-y-auto p-2 shrink-0">
-        <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide">世界状态</h3>
+        {/* 项目文件 section (top) */}
+        {ironRulesFile && (
+          <>
+            <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide">项目文件</h3>
+            <button
+              onClick={() => handleSelectFile(ironRulesFile)}
+              className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
+                selectedFile?.fileName === 'iron-rules.md'
+                  ? 'bg-indigo-100 text-indigo-700'
+                  : 'hover:bg-gray-50 text-gray-700'
+              }`}
+            >
+              <FileText className="inline h-3 w-3 mr-1 text-indigo-400" />
+              <span className="text-indigo-600 font-medium">项目铁律</span>
+            </button>
+          </>
+        )}
+        <h3 className={`text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide ${ironRulesFile ? 'mt-3' : 'mt-0'}`}>世界状态</h3>
         {allFiles.filter(f => f.category === 'world_state').map(file => (
         {allFiles.filter(f => f.category === 'world_state').map(file => (
           <button
           <button
             key={file.fileName}
             key={file.fileName}
             onClick={() => handleSelectFile(file)}
             onClick={() => handleSelectFile(file)}
             className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
             className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
-              selectedFile?.fileName === file.fileName && !selectedFile?.isLore
+              selectedFile?.fileName === file.fileName && !selectedFile?.isLore && selectedFile?.fileName !== 'iron-rules.md'
                 ? 'bg-indigo-100 text-indigo-700'
                 ? 'bg-indigo-100 text-indigo-700'
                 : 'hover:bg-gray-50 text-gray-700'
                 : 'hover:bg-gray-50 text-gray-700'
             }`}
             }`}
@@ -156,14 +218,38 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
                   {saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
                   {saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
                 </span>
                 </span>
               </div>
               </div>
-              <button
-                onClick={handleAIExpand}
-                disabled={isAiExpanding || !apiSettings}
-                className="flex items-center gap-1 px-3 py-1.5 text-xs text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors"
-              >
-                <Sparkles className="h-3.5 w-3.5" />
-                {isAiExpanding ? '扩展中...' : 'AI 扩展'}
-              </button>
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={handleAIExpand}
+                  disabled={isAiExpanding || !apiSettings}
+                  className="flex items-center gap-1 px-3 py-1.5 text-xs text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors"
+                >
+                  <Sparkles className="h-3.5 w-3.5" />
+                  {isAiExpanding ? '扩展中...' : 'AI 扩展'}
+                </button>
+                <button
+                  onClick={async () => {
+                    if (!selectedFile) return;
+                    const filePath = selectedFile.fileName === 'iron-rules.md'
+                      ? 'iron-rules.md'
+                      : selectedFile.isLore
+                        ? `world_state/lore/${selectedFile.fileName}`
+                        : `world_state/${selectedFile.fileName}`;
+                    await fetch('/api/novel/files', {
+                      method: 'POST',
+                      headers: { 'Content-Type': 'application/json' },
+                      body: JSON.stringify({ action: 'open', projectPath, filePath }),
+                    });
+                  }}
+                  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"
+                  title="在系统编辑器中打开"
+                >
+                  <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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+                  </svg>
+                  外部打开
+                </button>
+              </div>
             </div>
             </div>
             <textarea
             <textarea
               value={editContent}
               value={editContent}