فهرست منبع

feat: add PhaseNavigation and EventGraphEditor with dual timeline + NLN reordering

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 ماه پیش
والد
کامیت
8df07f1c9d

+ 353 - 0
src/app/features/novel-writer/components/EventGraphEditor.tsx

@@ -0,0 +1,353 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import type { EventNode, EventEdge, ChapterAllocation } from '@/app/lib/novel-types';
+import { createEventNode, reorderNarrative } from '@/app/lib/event-graph-utils';
+import { Plus, Trash2, ChevronRight, GripVertical, Search, CheckCircle, ChevronUp, ChevronDown } from 'lucide-react';
+
+interface EventGraphEditorProps {
+  projectPath: string;
+  apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
+}
+
+export default function EventGraphEditor({ projectPath, apiSettings }: EventGraphEditorProps) {
+  const [events, setEvents] = useState<EventNode[]>([]);
+  const [edges, setEdges] = useState<EventEdge[]>([]);
+  const [allocations, setAllocations] = useState<ChapterAllocation[]>([]);
+  const [selectedEvent, setSelectedEvent] = useState<EventNode | null>(null);
+  const [searchQuery, setSearchQuery] = useState('');
+  const [validationResult, setValidationResult] = useState<{ errors: string[]; warnings: string[] } | null>(null);
+  const [aiFeedback, setAiFeedback] = useState<string | null>(null);
+  const [isAllocating, setIsAllocating] = useState(false);
+  const [isValidating, setIsValidating] = useState(false);
+
+  // Load event graph
+  useEffect(() => {
+    fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`)
+      .then(r => r.json())
+      .then(data => {
+        if (data.success) {
+          setEvents(data.events || []);
+          setEdges(data.edges || []);
+          setAllocations(data.chapterAllocations || []);
+        }
+      })
+      .catch(() => {});
+  }, [projectPath]);
+
+  // Save events to server
+  const saveEvents = useCallback(async (updatedEvents: EventNode[]) => {
+    setEvents(updatedEvents);
+    await fetch('/api/novel/event-graph', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ projectPath, events: updatedEvents }),
+    }).catch(() => {});
+  }, [projectPath]);
+
+  // Save edges to server
+  const saveEdges = useCallback(async (updatedEdges: EventEdge[]) => {
+    setEdges(updatedEdges);
+    await fetch('/api/novel/event-graph', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ projectPath, edges: updatedEdges }),
+    }).catch(() => {});
+  }, [projectPath]);
+
+  // Add new event
+  const handleAddEvent = () => {
+    const node = createEventNode({
+      title: '新事件',
+      chronologicalOrder: events.length + 1,
+      narrativeOrder: events.length + 1,
+    });
+    const updated = [...events, node];
+    saveEvents(updated);
+    setSelectedEvent(node);
+  };
+
+  // Delete event
+  const handleDeleteEvent = (id: string) => {
+    const updatedEvents = events.filter(e => e.id !== id);
+    const updatedEdges = edges.filter(e => e.sourceId !== id && e.targetId !== id);
+    setEvents(updatedEvents);
+    setEdges(updatedEdges);
+    saveEvents(updatedEvents);
+    saveEdges(updatedEdges);
+    if (selectedEvent?.id === id) setSelectedEvent(null);
+  };
+
+  // Narrative reorder
+  const moveNarrativeUp = (eventId: string) => {
+    const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
+    const idx = sorted.findIndex(e => e.id === eventId);
+    if (idx <= 0) return;
+    const newOrder = [
+      ...sorted.slice(0, idx - 1).map(e => e.id),
+      sorted[idx].id,
+      sorted[idx - 1].id,
+      ...sorted.slice(idx + 1).map(e => e.id),
+    ];
+    const reordered = reorderNarrative(events, newOrder);
+    saveEvents(reordered);
+  };
+
+  const moveNarrativeDown = (eventId: string) => {
+    const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
+    const idx = sorted.findIndex(e => e.id === eventId);
+    if (idx < 0 || idx >= sorted.length - 1) return;
+    const newOrder = [
+      ...sorted.slice(0, idx).map(e => e.id),
+      sorted[idx + 1].id,
+      sorted[idx].id,
+      ...sorted.slice(idx + 2).map(e => e.id),
+    ];
+    const reordered = reorderNarrative(events, newOrder);
+    saveEvents(reordered);
+  };
+
+  // Update selected event field
+  const updateSelectedEvent = (field: keyof EventNode, value: unknown) => {
+    if (!selectedEvent) return;
+    const updated = events.map(e => e.id === selectedEvent.id ? { ...e, [field]: value } : e);
+    setEvents(updated);
+    setSelectedEvent({ ...selectedEvent, [field]: value as never });
+  };
+
+  // Auto-save on blur
+  const handleBlur = () => saveEvents(events);
+
+  // AI chapter allocation
+  const handleAIAllocate = async () => {
+    if (!apiSettings) return;
+    setIsAllocating(true);
+    try {
+      const res = await fetch('/api/novel/event-graph/allocate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, apiSettings }),
+      });
+      const data = await res.json();
+      if (data.success) setAllocations(data.allocations);
+    } finally {
+      setIsAllocating(false);
+    }
+  };
+
+  // AI validation
+  const handleValidate = async () => {
+    setIsValidating(true);
+    try {
+      const res = await fetch('/api/novel/event-graph/validate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, useAI: !!apiSettings, apiSettings }),
+      });
+      const data = await res.json();
+      if (data.success) {
+        setValidationResult(data.structural);
+        setAiFeedback(data.aiFeedback);
+      }
+    } finally {
+      setIsValidating(false);
+    }
+  };
+
+  // Filter events by search
+  const filteredEvents = searchQuery
+    ? events.filter(e => e.title.includes(searchQuery) || e.description.includes(searchQuery))
+    : events;
+
+  const chronologicalSorted = [...events].sort((a, b) => a.chronologicalOrder - b.chronologicalOrder);
+  const narrativeSorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
+
+  return (
+    <div className="flex h-full bg-white">
+      {/* Left panel: Event list */}
+      <div className="w-64 border-r border-gray-200 flex flex-col shrink-0">
+        <div className="p-3 border-b border-gray-100">
+          <div className="relative">
+            <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
+            <input
+              type="text"
+              value={searchQuery}
+              onChange={e => setSearchQuery(e.target.value)}
+              placeholder="搜索事件..."
+              className="w-full pl-7 pr-2 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300"
+            />
+          </div>
+        </div>
+        <div className="flex-1 overflow-y-auto">
+          {filteredEvents.map(event => {
+            const alloc = allocations.find(a => a.eventIds.includes(event.id));
+            return (
+              <button
+                key={event.id}
+                onClick={() => setSelectedEvent(event)}
+                className={`w-full text-left px-3 py-2 text-xs border-b border-gray-50 hover:bg-gray-50 transition-colors ${
+                  selectedEvent?.id === event.id ? 'bg-indigo-50 border-l-2 border-l-indigo-500' : ''
+                }`}
+              >
+                <div className="font-medium text-gray-800 truncate">{event.title}</div>
+                <div className="text-gray-400 mt-0.5 flex items-center gap-1">
+                  <span className={`inline-block w-1.5 h-1.5 rounded-full ${
+                    event.status === 'written' ? 'bg-green-500' : event.status === 'revised' ? 'bg-yellow-500' : 'bg-gray-300'
+                  }`} />
+                  {event.eventType} · {event.timeLabel || '未设置时间'}
+                  {alloc && <span className="text-indigo-400">· 第{alloc.chapterIndex}章</span>}
+                </div>
+              </button>
+            );
+          })}
+        </div>
+        <div className="p-2 border-t border-gray-200">
+          <button
+            onClick={handleAddEvent}
+            className="flex items-center gap-1 w-full px-3 py-2 text-xs text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
+          >
+            <Plus className="h-3.5 w-3.5" />
+            添加事件
+          </button>
+        </div>
+      </div>
+
+      {/* Center panel: Dual timeline */}
+      <div className="flex-1 flex">
+        <div className="flex-1 border-r border-gray-100 p-4 overflow-y-auto">
+          <h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">时间顺序</h3>
+          <div className="space-y-1">
+            {chronologicalSorted.map(event => (
+              <div
+                key={event.id}
+                className={`p-2 rounded-lg text-xs cursor-pointer transition-colors ${
+                  selectedEvent?.id === event.id ? 'bg-indigo-100 ring-1 ring-indigo-300' : 'hover:bg-gray-50'
+                }`}
+                onClick={() => setSelectedEvent(event)}
+              >
+                <span className="font-medium">{event.title}</span>
+                <span className="text-gray-400 ml-2">{event.timeLabel}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+        <div className="flex-1 p-4 overflow-y-auto">
+          <h3 className="text-xs font-semibold text-gray-500 mb-3 uppercase tracking-wide">叙事顺序(可调整)</h3>
+          <div className="space-y-1">
+            {narrativeSorted.map(event => (
+              <div
+                key={event.id}
+                className={`flex items-center gap-1 p-2 rounded-lg text-xs cursor-pointer transition-colors ${
+                  selectedEvent?.id === event.id ? 'bg-indigo-100 ring-1 ring-indigo-300' : 'hover:bg-gray-50'
+                } ${event.eventType === 'flashback' ? 'border-l-2 border-l-blue-400 pl-1.5' : ''}`}
+                onClick={() => setSelectedEvent(event)}
+              >
+                <div className="flex flex-col shrink-0 mr-1">
+                  <button onClick={(e) => { e.stopPropagation(); moveNarrativeUp(event.id); }} className="text-gray-300 hover:text-gray-600 leading-none">
+                    <ChevronUp className="h-3 w-3" />
+                  </button>
+                  <button onClick={(e) => { e.stopPropagation(); moveNarrativeDown(event.id); }} className="text-gray-300 hover:text-gray-600 leading-none">
+                    <ChevronDown className="h-3 w-3" />
+                  </button>
+                </div>
+                <GripVertical className="h-3 w-3 text-gray-300 shrink-0" />
+                <span className="font-medium">{event.title}</span>
+                {event.eventType === 'flashback' && <span className="text-blue-500 ml-1">↓倒叙</span>}
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      {/* Right panel: Event details */}
+      <div className="w-80 border-l border-gray-200 p-4 overflow-y-auto shrink-0">
+        {selectedEvent ? (
+          <div className="space-y-3">
+            <div className="flex items-center justify-between">
+              <h3 className="text-sm font-semibold text-gray-800">事件详情</h3>
+              <button onClick={() => handleDeleteEvent(selectedEvent.id)} className="text-red-400 hover:text-red-600 transition-colors" title="删除事件">
+                <Trash2 className="h-4 w-4" />
+              </button>
+            </div>
+            <div>
+              <label className="text-xs text-gray-500">标题</label>
+              <input type="text" value={selectedEvent.title}
+                onChange={e => updateSelectedEvent('title', e.target.value)} onBlur={handleBlur}
+                className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
+            </div>
+            <div>
+              <label className="text-xs text-gray-500">描述</label>
+              <textarea value={selectedEvent.description}
+                onChange={e => updateSelectedEvent('description', e.target.value)} onBlur={handleBlur} rows={3}
+                className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300 resize-none" />
+            </div>
+            <div className="grid grid-cols-2 gap-2">
+              <div>
+                <label className="text-xs text-gray-500">时间标签</label>
+                <input type="text" value={selectedEvent.timeLabel}
+                  onChange={e => updateSelectedEvent('timeLabel', e.target.value)} onBlur={handleBlur}
+                  className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
+              </div>
+              <div>
+                <label className="text-xs text-gray-500">地点</label>
+                <input type="text" value={selectedEvent.location}
+                  onChange={e => updateSelectedEvent('location', e.target.value)} onBlur={handleBlur}
+                  className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300" />
+              </div>
+            </div>
+            <div>
+              <label className="text-xs text-gray-500">事件类型</label>
+              <select value={selectedEvent.eventType}
+                onChange={e => updateSelectedEvent('eventType', e.target.value)} onBlur={handleBlur}
+                className="w-full mt-1 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-indigo-300">
+                <option value="main">主线</option>
+                <option value="sub">支线</option>
+                <option value="milestone">里程碑</option>
+                <option value="flashback">倒叙</option>
+                <option value="foreshadow">伏笔</option>
+              </select>
+            </div>
+            <div className="text-xs">
+              <span className="text-gray-500">章节目录:</span>
+              {(() => {
+                const alloc = allocations.find(a => a.eventIds.includes(selectedEvent.id));
+                return alloc
+                  ? <span className="text-green-600 font-medium">已分配到第 {alloc.chapterIndex} 章</span>
+                  : <span className="text-gray-400">未分配</span>;
+              })()}
+            </div>
+          </div>
+        ) : (
+          <div className="text-center text-gray-400 text-sm mt-12">选择一个事件查看和编辑详情</div>
+        )}
+
+        <div className="mt-6 pt-4 border-t border-gray-200 space-y-2">
+          <button onClick={handleAIAllocate} disabled={isAllocating || !apiSettings}
+            className="w-full px-3 py-2 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
+            {isAllocating ? '分配中...' : 'AI 自动分配章节'}
+          </button>
+          <button onClick={handleValidate} disabled={isValidating}
+            className="w-full px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors">
+            {isValidating ? '验证中...' : '验证事件图'}
+          </button>
+          {validationResult && (
+            <div className={`p-3 rounded-lg text-xs ${
+              validationResult.errors.length > 0 ? 'bg-red-50 text-red-700' :
+              validationResult.warnings.length > 0 ? 'bg-yellow-50 text-yellow-700' :
+              'bg-green-50 text-green-700'
+            }`}>
+              {validationResult.errors.map((e, i) => <div key={i}>• {e}</div>)}
+              {validationResult.warnings.map((w, i) => <div key={i}>• {w}</div>)}
+              {validationResult.errors.length === 0 && validationResult.warnings.length === 0 && (
+                <div className="flex items-center gap-1"><CheckCircle className="h-3.5 w-3.5" /> 通过验证</div>
+              )}
+            </div>
+          )}
+          {aiFeedback && (
+            <div className="p-3 rounded-lg text-xs bg-blue-50 text-blue-700">{aiFeedback}</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -1,7 +1,9 @@
 'use client';
 
 import { useState, useEffect, useCallback } from 'react';
-import { NovelType, NovelProject, WritingConstraint, AntiHomogenizationStrategy, WorldStateFile } from '@/app/lib/novel-types';
+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 { ApiProvider } from '@/app/components/ApiSettings';
 import ApiSettings from '@/app/components/ApiSettings';
 import WorldStateSidebar from '@/app/features/novel-writer/components/WorldStateSidebar';
@@ -108,6 +110,10 @@ export default function NovelContextManager({
     return PROMPT_TEMPLATES[0].id;
   });
 
+  // ── Phase 1 state ──
+  const [currentPhase, setCurrentPhase] = useState<NovelPhase>('phase2');
+  const [phase1Tab, setPhase1Tab] = useState<'world' | 'events'>('world');
+
   // ── Hooks ──
   const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
   const { chapters, saveChapter } = useChapterHistory(projectPath);
@@ -404,7 +410,35 @@ export default function NovelContextManager({
           </div>
         </header>
 
-        {/* Main content area */}
+        <PhaseNavigation currentPhase={currentPhase} onPhaseChange={setCurrentPhase} />
+
+        {currentPhase === 'phase1' && (
+          <div className="flex-1 flex flex-col overflow-hidden">
+            <div className="flex items-center gap-0 border-b border-gray-200 bg-white px-4 shrink-0">
+              {[
+                { id: 'world' as const, label: '世界观编辑' },
+                { id: 'events' 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 ${
+                    phase1Tab === tab.id ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700'
+                  }`}>
+                  {tab.label}
+                </button>
+              ))}
+            </div>
+            {phase1Tab === 'events' ? (
+              <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
+            ) : (
+              <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
+                世界观编辑器将在后续任务中实现
+              </div>
+            )}
+          </div>
+        )}
+
+        {currentPhase !== 'phase1' && (
+          <>
         {editingFile ? (
           <div className="flex-1 overflow-auto p-5">
             <div className="max-w-4xl mx-auto space-y-4">
@@ -569,6 +603,8 @@ export default function NovelContextManager({
             </div>
           </div>
         )}
+          </>
+        )}
       </div>
     </div>
   );

+ 39 - 0
src/app/features/novel-writer/components/PhaseNavigation.tsx

@@ -0,0 +1,39 @@
+'use client';
+
+import type { NovelPhase } from '@/app/lib/novel-types';
+
+interface PhaseNavigationProps {
+  currentPhase: NovelPhase;
+  onPhaseChange: (phase: NovelPhase) => void;
+}
+
+const PHASES: { id: NovelPhase; label: string; shortLabel: string }[] = [
+  { id: 'phase1', label: '世界构建', shortLabel: '构建' },
+  { id: 'phase2', label: '章节创作', shortLabel: '创作' },
+  { id: 'phase3', label: '维护迭代', shortLabel: '维护' },
+];
+
+export default function PhaseNavigation({ currentPhase, onPhaseChange }: PhaseNavigationProps) {
+  return (
+    <nav className="flex items-center gap-1 px-4 py-1.5 bg-white border-b border-gray-200">
+      {PHASES.map((phase, i) => (
+        <div key={phase.id} className="flex items-center">
+          <button
+            onClick={() => onPhaseChange(phase.id)}
+            className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
+              currentPhase === phase.id
+                ? 'bg-indigo-600 text-white shadow-sm'
+                : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
+            }`}
+            title={phase.label}
+          >
+            {phase.shortLabel}
+          </button>
+          {i < PHASES.length - 1 && (
+            <span className="mx-0.5 text-gray-300 text-xs">›</span>
+          )}
+        </div>
+      ))}
+    </nav>
+  );
+}