فهرست منبع

fix: pass chapter-scoped events to Step4, add edge creation UI to EventGraphEditor

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

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

@@ -346,6 +346,7 @@ export default function ChapterDraftPipeline({
               wordCountMax={wordCountMax}
               wordCountMax={wordCountMax}
               recentChaptersCount={recentChaptersCount}
               recentChaptersCount={recentChaptersCount}
               ironRules={ironRulesContent}
               ironRules={ironRulesContent}
+              chapterEvents={chapterEvents}
               onNext={handleStep2Next}
               onNext={handleStep2Next}
               onBack={() => setCurrentStep(1)}
               onBack={() => setCurrentStep(1)}
             />
             />

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

@@ -20,6 +20,7 @@ export default function EventGraphEditor({ projectPath, apiSettings }: EventGrap
   const [aiFeedback, setAiFeedback] = useState<string | null>(null);
   const [aiFeedback, setAiFeedback] = useState<string | null>(null);
   const [isAllocating, setIsAllocating] = useState(false);
   const [isAllocating, setIsAllocating] = useState(false);
   const [isValidating, setIsValidating] = useState(false);
   const [isValidating, setIsValidating] = useState(false);
+  const [newEdgeType, setNewEdgeType] = useState<EventEdge['relationType']>('causal');
 
 
   // Load event graph
   // Load event graph
   useEffect(() => {
   useEffect(() => {
@@ -78,6 +79,20 @@ export default function EventGraphEditor({ projectPath, apiSettings }: EventGrap
     if (selectedEvent?.id === id) setSelectedEvent(null);
     if (selectedEvent?.id === id) setSelectedEvent(null);
   };
   };
 
 
+  // Add edge
+  const handleAddEdge = (targetId: string) => {
+    if (!targetId || !selectedEvent) return;
+    const newEdge: EventEdge = {
+      sourceId: selectedEvent.id,
+      targetId,
+      relationType: newEdgeType,
+    };
+    if (edges.some(ex => ex.sourceId === newEdge.sourceId && ex.targetId === newEdge.targetId)) return;
+    const updated = [...edges, newEdge];
+    setEdges(updated);
+    saveEdges(updated);
+  };
+
   // Narrative reorder
   // Narrative reorder
   const moveNarrativeUp = (eventId: string) => {
   const moveNarrativeUp = (eventId: string) => {
     const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
     const sorted = [...events].sort((a, b) => a.narrativeOrder - b.narrativeOrder);
@@ -227,6 +242,16 @@ export default function EventGraphEditor({ projectPath, apiSettings }: EventGrap
               >
               >
                 <span className="font-medium">{event.title}</span>
                 <span className="font-medium">{event.title}</span>
                 <span className="text-gray-400 ml-2">{event.timeLabel}</span>
                 <span className="text-gray-400 ml-2">{event.timeLabel}</span>
+                {(() => {
+                  const hasOutgoing = edges.some(e => e.sourceId === event.id);
+                  const hasIncoming = edges.some(e => e.targetId === event.id);
+                  return (hasOutgoing || hasIncoming) ? (
+                    <span className="flex gap-0.5 ml-1">
+                      {hasOutgoing && <span className="w-1 h-1 rounded-full bg-red-400" title="有出边" />}
+                      {hasIncoming && <span className="w-1 h-1 rounded-full bg-blue-400" title="有入边" />}
+                    </span>
+                  ) : null;
+                })()}
               </div>
               </div>
             ))}
             ))}
           </div>
           </div>
@@ -252,6 +277,16 @@ export default function EventGraphEditor({ projectPath, apiSettings }: EventGrap
                 </div>
                 </div>
                 <GripVertical className="h-3 w-3 text-gray-300 shrink-0" />
                 <GripVertical className="h-3 w-3 text-gray-300 shrink-0" />
                 <span className="font-medium">{event.title}</span>
                 <span className="font-medium">{event.title}</span>
+                {(() => {
+                  const hasOutgoing = edges.some(e => e.sourceId === event.id);
+                  const hasIncoming = edges.some(e => e.targetId === event.id);
+                  return (hasOutgoing || hasIncoming) ? (
+                    <span className="flex gap-0.5 ml-1">
+                      {hasOutgoing && <span className="w-1 h-1 rounded-full bg-red-400" title="有出边" />}
+                      {hasIncoming && <span className="w-1 h-1 rounded-full bg-blue-400" title="有入边" />}
+                    </span>
+                  ) : null;
+                })()}
                 {event.eventType === 'flashback' && <span className="text-blue-500 ml-1">↓倒叙</span>}
                 {event.eventType === 'flashback' && <span className="text-blue-500 ml-1">↓倒叙</span>}
               </div>
               </div>
             ))}
             ))}
@@ -316,6 +351,58 @@ export default function EventGraphEditor({ projectPath, apiSettings }: EventGrap
                   : <span className="text-gray-400">未分配</span>;
                   : <span className="text-gray-400">未分配</span>;
               })()}
               })()}
             </div>
             </div>
+
+            {/* Edge management */}
+            <div className="mt-4 pt-3 border-t border-gray-200">
+              <label className="text-xs text-gray-500">关联事件</label>
+              <div className="space-y-1 mt-1">
+                {edges
+                  .filter(e => e.sourceId === selectedEvent.id || e.targetId === selectedEvent.id)
+                  .map((edge, i) => {
+                    const otherId = edge.sourceId === selectedEvent.id ? edge.targetId : edge.sourceId;
+                    const otherEvent = events.find(e => e.id === otherId);
+                    const direction = edge.sourceId === selectedEvent.id ? '→' : '←';
+                    const relLabel = edge.relationType === 'causal' ? '因果' : edge.relationType === 'temporal' ? '时序' : edge.relationType === 'parallel' ? '并列' : '对比';
+                    return (
+                      <div key={i} className="flex items-center gap-1 text-xs text-gray-600">
+                        <span className="text-gray-400">{direction}</span>
+                        <span>{otherEvent?.title || otherId.slice(0, 8)}</span>
+                        <span className="text-gray-400">({relLabel})</span>
+                        <button onClick={() => {
+                          const updated = edges.filter(e => !(e.sourceId === edge.sourceId && e.targetId === edge.targetId));
+                          setEdges(updated);
+                          saveEdges(updated);
+                        }} className="ml-auto text-red-400 hover:text-red-600 text-xs">×</button>
+                      </div>
+                    );
+                  })}
+              </div>
+              <div className="flex gap-1 mt-2">
+                <select
+                  className="flex-1 px-1 py-1 text-xs border border-gray-200 rounded"
+                  onChange={(e) => {
+                    handleAddEdge(e.target.value);
+                    e.target.value = '';
+                  }}
+                  defaultValue=""
+                >
+                  <option value="" disabled>添加关联...</option>
+                  {events.filter(e => e.id !== selectedEvent.id).map(e => (
+                    <option key={e.id} value={e.id}>{e.title}</option>
+                  ))}
+                </select>
+                <select
+                  className="w-16 px-1 py-1 text-xs border border-gray-200 rounded"
+                  value={newEdgeType}
+                  onChange={(e) => setNewEdgeType(e.target.value as EventEdge['relationType'])}
+                >
+                  <option value="causal">因果</option>
+                  <option value="temporal">时序</option>
+                  <option value="parallel">并列</option>
+                  <option value="contrast">对比</option>
+                </select>
+              </div>
+            </div>
           </div>
           </div>
         ) : (
         ) : (
           <div className="text-center text-gray-400 text-sm mt-12">选择一个事件查看和编辑详情</div>
           <div className="text-center text-gray-400 text-sm mt-12">选择一个事件查看和编辑详情</div>

+ 2 - 18
src/app/features/novel-writer/components/pipeline/Step4ContentEditor.tsx

@@ -24,6 +24,7 @@ interface Step4ContentEditorProps {
   wordCountMax: number;
   wordCountMax: number;
   recentChaptersCount: number;
   recentChaptersCount: number;
   ironRules?: string;
   ironRules?: string;
+  chapterEvents?: EventNode[];
   onNext: (content: string) => void;
   onNext: (content: string) => void;
   onBack: () => void;
   onBack: () => void;
 }
 }
@@ -87,6 +88,7 @@ export default function Step4ContentEditor({
   wordCountMax,
   wordCountMax,
   recentChaptersCount,
   recentChaptersCount,
   ironRules,
   ironRules,
+  chapterEvents = [],
   onNext,
   onNext,
   onBack,
   onBack,
 }: Step4ContentEditorProps) {
 }: Step4ContentEditorProps) {
@@ -387,15 +389,6 @@ ${antiText}
         }
         }
       }
       }
 
 
-      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(
       const worldContext = buildLayeredContext(
         worldStateFiles,
         worldStateFiles,
         loreFiles,
         loreFiles,
@@ -498,15 +491,6 @@ ${antiText}
         }
         }
       }
       }
 
 
-      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(
       const worldContext = buildLayeredContext(
         worldStateFiles,
         worldStateFiles,
         loreFiles,
         loreFiles,