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

feat: add WorldEditor, wire Phase 1 tabs, auto-update event status on save

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

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

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
 import { NovelType, NovelProject, WritingConstraint, AntiHomogenizationStrategy, WorldStateFile, NovelPhase, WorkflowState } from '@/app/lib/novel-types';
 import PhaseNavigation from '@/app/features/novel-writer/components/PhaseNavigation';
 import EventGraphEditor from '@/app/features/novel-writer/components/EventGraphEditor';
+import WorldEditor from '@/app/features/novel-writer/components/WorldEditor';
 import { ApiProvider } from '@/app/components/ApiSettings';
 import ApiSettings from '@/app/components/ApiSettings';
 import WorldStateSidebar from '@/app/features/novel-writer/components/WorldStateSidebar';
@@ -11,6 +12,7 @@ import ChapterDraftPipeline from '@/app/features/novel-writer/components/Chapter
 import ChapterHistoryList from '@/app/features/novel-writer/components/ChapterHistoryList';
 import WritingConstraintManager from '@/app/features/novel-writer/components/WritingConstraintManager';
 import AntiHomogenizationPanel from '@/app/features/novel-writer/components/AntiHomogenizationPanel';
+import MaintenancePanel from '@/app/features/novel-writer/components/MaintenancePanel';
 import { useWorldState } from '@/app/hooks/useWorldState';
 import { useChapterHistory } from '@/app/hooks/useChapterHistory';
 
@@ -448,17 +450,19 @@ export default function NovelContextManager({
                 </button>
               ))}
             </div>
-            {phase1Tab === 'events' ? (
-              <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
+            {phase1Tab === 'world' ? (
+              <WorldEditor projectPath={projectPath} apiSettings={apiSettings} />
             ) : (
-              <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
-                世界观编辑器将在后续任务中实现
-              </div>
+              <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
             )}
           </div>
         )}
 
-        {currentPhase !== 'phase1' && (
+        {currentPhase === 'phase3' && (
+          <MaintenancePanel projectPath={projectPath} apiSettings={apiSettings} />
+        )}
+
+        {currentPhase !== 'phase1' && currentPhase !== 'phase3' && (
           <>
         {editingFile ? (
           <div className="flex-1 overflow-auto p-5">

+ 159 - 0
src/app/features/novel-writer/components/WorldEditor.tsx

@@ -0,0 +1,159 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { FileText, Sparkles } from 'lucide-react';
+import type { WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
+import { useWorldState } from '@/app/hooks/useWorldState';
+
+interface WorldEditorProps {
+  projectPath: string;
+  apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
+}
+
+export default function WorldEditor({ projectPath, apiSettings }: WorldEditorProps) {
+  const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
+  const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
+  const [editContent, setEditContent] = useState('');
+  const [isAiExpanding, setIsAiExpanding] = useState(false);
+  const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
+
+  const allFiles = [
+    ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
+    ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
+  ];
+
+  // Auto-save on content change (debounced 1.5s)
+  useEffect(() => {
+    if (!selectedFile || editContent === selectedFile.content) return;
+    const timer = setTimeout(async () => {
+      setSaveStatus('saving');
+      try {
+        await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
+        setSaveStatus('saved');
+        setTimeout(() => setSaveStatus('idle'), 2000);
+      } catch { setSaveStatus('idle'); }
+    }, 1500);
+    return () => clearTimeout(timer);
+  }, [editContent, selectedFile, updateFile]);
+
+  const handleSelectFile = (file: WorldStateFile) => {
+    setSelectedFile(file);
+    setEditContent(file.content);
+    setSaveStatus('idle');
+  };
+
+  const handleAIExpand = async () => {
+    if (!apiSettings || !selectedFile || !editContent) return;
+    setIsAiExpanding(true);
+    try {
+      const res = await fetch('/api/stream-proxy', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          apiUrl: apiSettings.apiUrl,
+          apiKey: apiSettings.apiKey,
+          model: apiSettings.model,
+          messages: [{
+            role: 'user',
+            content: `请基于以下现有内容,扩展和丰富这个小说设定文件。保持原有结构和风格,增加更多具体细节。\n\n## 当前内容\n${editContent}\n\n## 要求\n- 保持 Markdown 格式\n- 扩展内容要符合世界观逻辑\n- 增加具象化细节(避免抽象描述)\n- 保持原有章节结构`,
+          }],
+        }),
+      });
+
+      const reader = res.body?.getReader();
+      if (!reader) { setIsAiExpanding(false); return; }
+      let result = '';
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        const text = new TextDecoder().decode(value);
+        const lines = text.split('\n').filter(l => l.startsWith('data: '));
+        for (const line of lines) {
+          try {
+            const json = JSON.parse(line.slice(6));
+            if (json.choices?.[0]?.delta?.content) {
+              result += json.choices[0].delta.content;
+            }
+          } catch { /* partial chunk */ }
+        }
+      }
+      if (result) setEditContent(prev => prev + '\n\n' + result);
+    } catch { /* ignore */ }
+    finally { setIsAiExpanding(false); }
+  };
+
+  return (
+    <div className="flex h-full bg-white">
+      {/* File tree sidebar */}
+      <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>
+        {allFiles.filter(f => f.category === 'world_state').map(file => (
+          <button
+            key={file.fileName}
+            onClick={() => handleSelectFile(file)}
+            className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
+              selectedFile?.fileName === file.fileName && !selectedFile?.isLore
+                ? 'bg-indigo-100 text-indigo-700'
+                : 'hover:bg-gray-50 text-gray-700'
+            }`}
+          >
+            <FileText className="inline h-3 w-3 mr-1" />
+            {file.displayName || file.fileName}
+          </button>
+        ))}
+        <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mt-3 mb-1 uppercase tracking-wide">世界观设定</h3>
+        {allFiles.filter(f => f.category === 'lore').map(file => (
+          <button
+            key={file.fileName}
+            onClick={() => handleSelectFile(file)}
+            className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
+              selectedFile?.fileName === file.fileName && selectedFile?.isLore
+                ? 'bg-indigo-100 text-indigo-700'
+                : 'hover:bg-gray-50 text-gray-700'
+            }`}
+          >
+            <FileText className="inline h-3 w-3 mr-1" />
+            {file.displayName || file.fileName}
+          </button>
+        ))}
+      </div>
+
+      {/* Editor area */}
+      <div className="flex-1 flex flex-col">
+        {selectedFile ? (
+          <>
+            <div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 shrink-0">
+              <div className="flex items-center gap-2">
+                <h2 className="text-sm font-semibold text-gray-800">
+                  {selectedFile.displayName || selectedFile.fileName}
+                </h2>
+                <span className="text-xs text-gray-400">
+                  {saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
+                </span>
+              </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>
+            <textarea
+              value={editContent}
+              onChange={e => setEditContent(e.target.value)}
+              className="flex-1 w-full p-4 text-sm font-mono border-0 resize-none focus:outline-none"
+              placeholder="文件内容..."
+              spellCheck={false}
+            />
+          </>
+        ) : (
+          <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
+            从左侧选择文件开始编辑
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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

@@ -2,7 +2,7 @@
 
 import { useState, useEffect, useCallback } from 'react';
 import { ChevronLeft, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Save, Sparkles } from 'lucide-react';
-import { WorldStateFile, WorldLoreFile, FileUpdateResult, NarrativeHook } from '@/app/lib/novel-types';
+import { WorldStateFile, WorldLoreFile, FileUpdateResult, NarrativeHook, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
 import { useStateUpdater } from '@/app/hooks/useStateUpdater';
 import { resolveHook, addHooks } from '@/app/lib/novel-utils';
 
@@ -336,6 +336,29 @@ export default function Step5ReviewSubmit({
       // 2. 处理钩子更新
       await updateHooksInJson();
 
+      // 3. After successful chapter save, mark chapter events as written
+      try {
+        const graphRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
+        const graphData = await graphRes.json();
+        if (graphData.success && graphData.chapterAllocations) {
+          const alloc = (graphData.chapterAllocations as ChapterAllocation[]).find(
+            a => a.chapterIndex === chapterIndex
+          );
+          if (alloc) {
+            const updatedEvents = (graphData.events as EventNode[]).map(e =>
+              alloc.eventIds.includes(e.id)
+                ? { ...e, status: 'written' as const, chapterIndex }
+                : e
+            );
+            await fetch('/api/novel/event-graph', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ projectPath, events: updatedEvents }),
+            });
+          }
+        }
+      } catch { /* non-critical: event status update is optional */ }
+
       setSaveSuccess(true);
       setTimeout(() => {
         onComplete(chapterTitle.trim());