|
|
@@ -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);
|