|
|
@@ -1,856 +1,856 @@
|
|
|
-'use client';
|
|
|
-
|
|
|
-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, EventNode, WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
|
|
|
-import { buildAuthenticityPassPrompt } from '@/app/lib/chapter-prompts';
|
|
|
-
|
|
|
-interface ApiSettings {
|
|
|
- apiUrl: string;
|
|
|
- apiKey: string;
|
|
|
- model: string;
|
|
|
-}
|
|
|
-
|
|
|
-interface Step4ContentEditorProps {
|
|
|
- projectPath: string;
|
|
|
- selectedPlot: string;
|
|
|
- embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
|
|
|
- manualHooks: string[];
|
|
|
- resolveHookIds: string[];
|
|
|
- savedChapterContent: string;
|
|
|
- apiSettings: ApiSettings | null;
|
|
|
- writingConstraints: WritingConstraint[];
|
|
|
- antiHomogenizationStrategies: AntiHomogenizationStrategy[];
|
|
|
- wordCountMin: number;
|
|
|
- wordCountMax: number;
|
|
|
- recentChaptersCount: number;
|
|
|
- ironRules?: string;
|
|
|
- onNext: (content: string) => void;
|
|
|
- onBack: () => void;
|
|
|
-}
|
|
|
-
|
|
|
-const ANTI_HOMOGENIZATION_LABELS: Record<AntiHomogenizationStrategy, string> = {
|
|
|
- perspective_rotation: '叙事视角轮换',
|
|
|
- sensory_enhancement: '场景感官强化',
|
|
|
- authentic_detail: '真实细节注入',
|
|
|
- rhythm_contrast: '节奏对比',
|
|
|
- dialogue_personalization: '对话个性化',
|
|
|
- unexpected_element: '意外元素',
|
|
|
-};
|
|
|
-
|
|
|
-function countChineseWords(text: string): number {
|
|
|
- const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
|
- const englishWords = (text.match(/\b[a-zA-Z]+\b/g) || []).length;
|
|
|
- return chineseChars + englishWords;
|
|
|
-}
|
|
|
-
|
|
|
-function buildLayeredContext(
|
|
|
- worldStateFiles: WorldStateFile[],
|
|
|
- loreFiles: WorldLoreFile[],
|
|
|
- chapterEvents: EventNode[],
|
|
|
- ironRules: string,
|
|
|
- rollingSummaries: string[]
|
|
|
-): string {
|
|
|
- const parts: string[] = [];
|
|
|
-
|
|
|
- // Layer 1: Always inject (iron-rules, current chapter events)
|
|
|
- if (ironRules) parts.push(`## \u9879\u76ee\u94c1\u5f8b\n${ironRules}`);
|
|
|
- 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,
|
|
|
- embedHooks,
|
|
|
- manualHooks,
|
|
|
- resolveHookIds,
|
|
|
- savedChapterContent,
|
|
|
- apiSettings,
|
|
|
- writingConstraints,
|
|
|
- antiHomogenizationStrategies,
|
|
|
- wordCountMin,
|
|
|
- wordCountMax,
|
|
|
- recentChaptersCount,
|
|
|
- ironRules,
|
|
|
- onNext,
|
|
|
- onBack,
|
|
|
-}: Step4ContentEditorProps) {
|
|
|
- const [content, setContent] = useState(savedChapterContent);
|
|
|
- const [isGenerating, setIsGenerating] = useState(false);
|
|
|
- const [error, setError] = useState<string | null>(null);
|
|
|
- const [hasGenerated, setHasGenerated] = useState(!!savedChapterContent);
|
|
|
- const [mainTokenUsage, setMainTokenUsage] = useState<string | null>(null);
|
|
|
- const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
- const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
-
|
|
|
- const [selectionStart, setSelectionStart] = useState(0);
|
|
|
- const [selectionEnd, setSelectionEnd] = useState(0);
|
|
|
- const [isPolishing, setIsPolishing] = useState(false);
|
|
|
- const [polishResult, setPolishResult] = useState<{ original: string; polished: string } | null>(null);
|
|
|
- const [polishError, setPolishError] = useState<string | null>(null);
|
|
|
- const [polishTokenUsage, setPolishTokenUsage] = useState<string | null>(null);
|
|
|
-
|
|
|
- // 真实性修订(去 AI 味)
|
|
|
- const [isDeAI, setIsDeAI] = useState(false);
|
|
|
- const [deAIError, setDeAIError] = useState<string | null>(null);
|
|
|
-
|
|
|
- const [showParagraphPanel, setShowParagraphPanel] = useState(false);
|
|
|
- const [paragraphTopic, setParagraphTopic] = useState('');
|
|
|
- const [paragraphRequirements, setParagraphRequirements] = useState('');
|
|
|
- const [isGeneratingParagraph, setIsGeneratingParagraph] = useState(false);
|
|
|
- const [paragraphResult, setParagraphResult] = useState<string | null>(null);
|
|
|
- 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;
|
|
|
- const wordCountWarning =
|
|
|
- content.length > 0 && !isWordCountInRange
|
|
|
- ? wordCount < wordCountMin
|
|
|
- ? `字数不足:当前 ${wordCount} 字,目标最少 ${wordCountMin} 字`
|
|
|
- : `字数超出:当前 ${wordCount} 字,目标最多 ${wordCountMax} 字`
|
|
|
- : null;
|
|
|
-
|
|
|
- const buildGenerationPrompt = useCallback(
|
|
|
- async (worldContext: string): Promise<string> => {
|
|
|
- const enabledConstraints = writingConstraints
|
|
|
- .filter((c) => c.enabled)
|
|
|
- .map((c) => `- ${c.label}`)
|
|
|
- .join('\n');
|
|
|
-
|
|
|
- const antiStrategies = antiHomogenizationStrategies
|
|
|
- .map((s) => `- ${ANTI_HOMOGENIZATION_LABELS[s]}`)
|
|
|
- .join('\n');
|
|
|
-
|
|
|
- const embedHooksText = embedHooks.length > 0
|
|
|
- ? `\n本章需要埋入的叙事钩子:\n${embedHooks.map((h) => `- [${h.type}] ${h.description}(埋入方式:${h.embedMethod})`).join('\n')}`
|
|
|
- : '';
|
|
|
-
|
|
|
- const manualHooksText = manualHooks.length > 0
|
|
|
- ? `\n手动添加的叙事钩子:\n${manualHooks.map((h) => `- ${h}`).join('\n')}`
|
|
|
- : '';
|
|
|
-
|
|
|
- const resolveHooksText = resolveHookIds.length > 0
|
|
|
- ? `\n本章需要解决的历史钩子 ID:\n${resolveHookIds.map((id) => `- ${id}`).join('\n')}`
|
|
|
- : '';
|
|
|
-
|
|
|
- const hooksText = embedHooksText + manualHooksText + resolveHooksText;
|
|
|
-
|
|
|
- const constraintsText = enabledConstraints
|
|
|
- ? `\n写作约束(必须满足):\n${enabledConstraints}`
|
|
|
- : '';
|
|
|
-
|
|
|
- const antiText = antiStrategies
|
|
|
- ? `\n反同质化策略(请在章节中体现):\n${antiStrategies}`
|
|
|
- : '';
|
|
|
-
|
|
|
- // Fetch style guide and interview files for injection
|
|
|
- let contextText = '';
|
|
|
- try {
|
|
|
- const styleRes = await fetch(
|
|
|
- `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
|
|
|
- );
|
|
|
- const styleData = await styleRes.json();
|
|
|
- if (styleData.success && Array.isArray(styleData.files)) {
|
|
|
- const styleFile = styleData.files.find(
|
|
|
- (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
|
|
|
- );
|
|
|
- if (styleFile?.content) contextText += `\n【文风设定】\n${styleFile.content}\n`;
|
|
|
- }
|
|
|
- } catch { /* ignore */ }
|
|
|
-
|
|
|
- // Fetch interview project files for context
|
|
|
- const interviewFiles = ['00-project-overview.md', '01-theme-and-proposition.md', '03-cast-bible.md'];
|
|
|
- for (const fileName of interviewFiles) {
|
|
|
- try {
|
|
|
- const res = await fetch('/api/novel/files', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({ action: 'read', projectPath, filePath: fileName }),
|
|
|
- });
|
|
|
- if (res.ok) {
|
|
|
- const data = await res.json();
|
|
|
- if (data.content) contextText += `\n【${fileName.replace('.md', '')}】\n${data.content}\n`;
|
|
|
- }
|
|
|
- } catch { /* file may not exist */ }
|
|
|
- }
|
|
|
-
|
|
|
- const antiDriftSection = ironRules ? `## 项目铁律\n${ironRules}` : '';
|
|
|
-
|
|
|
- return `${worldContext}${contextText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
|
|
|
-
|
|
|
-请根据以下要求创作本章完整内容:
|
|
|
-
|
|
|
-【剧情方向】
|
|
|
-${selectedPlot}
|
|
|
-${hooksText}
|
|
|
-${constraintsText}
|
|
|
-${antiText}
|
|
|
-
|
|
|
-【字数要求】
|
|
|
-本章字数需在 ${wordCountMin} 到 ${wordCountMax} 字之间。
|
|
|
-
|
|
|
-【创作要求】
|
|
|
-1. 严格遵循上方世界状态和文风设定
|
|
|
-2. 完整呈现选定的剧情方向
|
|
|
-3. 自然地埋入所有选定的叙事钩子
|
|
|
-4. 如果有需要解决的历史钩子,在本章中合理地揭示或解决
|
|
|
-5. 满足所有写作约束
|
|
|
-6. 体现反同质化策略,避免风格同质化
|
|
|
-7. 直接输出章节正文,不要包含标题或额外说明
|
|
|
-
|
|
|
-请开始创作:`;
|
|
|
- },
|
|
|
- [
|
|
|
- projectPath,
|
|
|
- selectedPlot,
|
|
|
- embedHooks,
|
|
|
- manualHooks,
|
|
|
- resolveHookIds,
|
|
|
- writingConstraints,
|
|
|
- antiHomogenizationStrategies,
|
|
|
- wordCountMin,
|
|
|
- wordCountMax,
|
|
|
- ironRules,
|
|
|
- ]
|
|
|
- );
|
|
|
-
|
|
|
- const fetchWritingStyle = useCallback(async (): Promise<string> => {
|
|
|
- try {
|
|
|
- const res = await fetch(
|
|
|
- `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
|
|
|
- );
|
|
|
- const data = await res.json();
|
|
|
- if (data.success && Array.isArray(data.files)) {
|
|
|
- const styleFile = data.files.find(
|
|
|
- (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
|
|
|
- );
|
|
|
- return styleFile?.content?.trim() ?? '';
|
|
|
- }
|
|
|
- } catch {
|
|
|
- // 忽略错误
|
|
|
- }
|
|
|
- return '';
|
|
|
- }, [projectPath]);
|
|
|
-
|
|
|
- const handlePolish = useCallback(async () => {
|
|
|
- if (!apiSettings) {
|
|
|
- setPolishError('请先配置 API 设置');
|
|
|
- return;
|
|
|
- }
|
|
|
- const selectedText = content.slice(selectionStart, selectionEnd);
|
|
|
- if (!selectedText.trim()) return;
|
|
|
-
|
|
|
- setIsPolishing(true);
|
|
|
- setPolishError(null);
|
|
|
- setPolishResult(null);
|
|
|
- setPolishTokenUsage(null);
|
|
|
-
|
|
|
- try {
|
|
|
- const writingStyle = await fetchWritingStyle();
|
|
|
- const styleSection = writingStyle
|
|
|
- ? `文风参考:\n${writingStyle}\n\n`
|
|
|
- : '';
|
|
|
-
|
|
|
- const prompt = `请对以下文字进行润色,保持原意不变,但提升文学性和可读性。\n\n${styleSection}原文:\n${selectedText}\n\n请直接返回润色后的文字,不要包含任何解释。`;
|
|
|
-
|
|
|
- const response = await fetch('/api/proxy', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({
|
|
|
- targetUrl: apiSettings.apiUrl,
|
|
|
- headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
- body: {
|
|
|
- model: apiSettings.model,
|
|
|
- messages: [{ role: 'user', content: prompt }],
|
|
|
- stream: false,
|
|
|
- },
|
|
|
- }),
|
|
|
- });
|
|
|
-
|
|
|
- if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
- const data = await response.json();
|
|
|
- const polished =
|
|
|
- data?.choices?.[0]?.message?.content?.trim() ??
|
|
|
- data?.choices?.[0]?.text?.trim() ??
|
|
|
- '';
|
|
|
-
|
|
|
- if (!polished) throw new Error('AI 返回内容为空');
|
|
|
- setPolishResult({ original: selectedText, polished });
|
|
|
- if (data?.usage?.total_tokens) {
|
|
|
- setPolishTokenUsage(
|
|
|
- `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
|
|
|
- );
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- setPolishError(err instanceof Error ? err.message : '润色失败,请重试');
|
|
|
- } finally {
|
|
|
- setIsPolishing(false);
|
|
|
- }
|
|
|
- }, [apiSettings, content, selectionStart, selectionEnd, fetchWritingStyle]);
|
|
|
-
|
|
|
- const acceptPolish = useCallback(() => {
|
|
|
- if (!polishResult) return;
|
|
|
- const newContent =
|
|
|
- content.slice(0, selectionStart) +
|
|
|
- polishResult.polished +
|
|
|
- content.slice(selectionEnd);
|
|
|
- setContent(newContent);
|
|
|
- setPolishResult(null);
|
|
|
- setSelectionStart(0);
|
|
|
- setSelectionEnd(0);
|
|
|
- }, [polishResult, content, selectionStart, selectionEnd]);
|
|
|
-
|
|
|
- const rejectPolish = useCallback(() => {
|
|
|
- setPolishResult(null);
|
|
|
- setPolishError(null);
|
|
|
- }, []);
|
|
|
-
|
|
|
- // 真实性修订(去 AI 味)- 对全文进行三步修订
|
|
|
- const handleDeAI = useCallback(async () => {
|
|
|
- if (!content.trim() || !apiSettings) return;
|
|
|
- setIsDeAI(true);
|
|
|
- setDeAIError(null);
|
|
|
- try {
|
|
|
- const prompt = buildAuthenticityPassPrompt(content, '');
|
|
|
- const requestBody = {
|
|
|
- model: apiSettings.model,
|
|
|
- messages: [
|
|
|
- { role: 'system' as const, content: '你是小说文字编辑,擅长去除AI痕迹、恢复真实语感。用中文输出。' },
|
|
|
- { role: 'user' as const, content: prompt },
|
|
|
- ],
|
|
|
- temperature: 0.4,
|
|
|
- stream: false,
|
|
|
- };
|
|
|
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
|
- if (apiSettings.apiKey) headers['Authorization'] = `Bearer ${apiSettings.apiKey}`;
|
|
|
- const res = await fetch('/api/proxy', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({
|
|
|
- targetUrl: apiSettings.apiUrl,
|
|
|
- headers,
|
|
|
- body: requestBody,
|
|
|
- isOllama: apiSettings.apiUrl.includes('11434'),
|
|
|
- }),
|
|
|
- });
|
|
|
- if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
|
- const data = await res.json();
|
|
|
- const deAIContent = data.choices?.[0]?.message?.content;
|
|
|
- if (deAIContent) { setContent(deAIContent); }
|
|
|
- } catch (err) {
|
|
|
- setDeAIError(err instanceof Error ? err.message : '修订失败');
|
|
|
- } finally {
|
|
|
- setIsDeAI(false);
|
|
|
- }
|
|
|
- }, [content, apiSettings]);
|
|
|
-
|
|
|
- const handleGenerateParagraph = useCallback(async () => {
|
|
|
- if (!apiSettings) {
|
|
|
- setParagraphError('请先配置 API 设置');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (!paragraphTopic.trim()) {
|
|
|
- setParagraphError('请输入段落主题');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- setIsGeneratingParagraph(true);
|
|
|
- setParagraphError(null);
|
|
|
- setParagraphResult(null);
|
|
|
- setParagraphTokenUsage(null);
|
|
|
-
|
|
|
- try {
|
|
|
- const filesRes = await fetch(
|
|
|
- `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
|
|
|
- );
|
|
|
- const filesData = await filesRes.json();
|
|
|
-
|
|
|
- const worldStateFiles: WorldStateFile[] = [];
|
|
|
- const loreFiles: WorldLoreFile[] = [];
|
|
|
- if (filesData.success && Array.isArray(filesData.files)) {
|
|
|
- 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 || '',
|
|
|
- rollingSummaries
|
|
|
- );
|
|
|
-
|
|
|
- const requirementsSection = paragraphRequirements.trim()
|
|
|
- ? `要求:${paragraphRequirements.trim()}\n`
|
|
|
- : '';
|
|
|
-
|
|
|
- const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
|
|
|
-
|
|
|
- const response = await fetch('/api/proxy', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({
|
|
|
- targetUrl: apiSettings.apiUrl,
|
|
|
- headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
- body: {
|
|
|
- model: apiSettings.model,
|
|
|
- messages: [{ role: 'user', content: prompt }],
|
|
|
- stream: false,
|
|
|
- },
|
|
|
- }),
|
|
|
- });
|
|
|
-
|
|
|
- if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
- const data = await response.json();
|
|
|
- const generated =
|
|
|
- data?.choices?.[0]?.message?.content?.trim() ??
|
|
|
- data?.choices?.[0]?.text?.trim() ??
|
|
|
- '';
|
|
|
-
|
|
|
- if (!generated) throw new Error('AI 返回内容为空');
|
|
|
- setParagraphResult(generated);
|
|
|
- if (data?.usage?.total_tokens) {
|
|
|
- setParagraphTokenUsage(
|
|
|
- `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
|
|
|
- );
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- setParagraphError(err instanceof Error ? err.message : '段落生成失败,请重试');
|
|
|
- } finally {
|
|
|
- setIsGeneratingParagraph(false);
|
|
|
- }
|
|
|
- }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements, ironRules, rollingSummaries]);
|
|
|
-
|
|
|
- const acceptParagraph = useCallback(() => {
|
|
|
- if (!paragraphResult) return;
|
|
|
- const insertAt = cursorPosition;
|
|
|
- const newContent =
|
|
|
- content.slice(0, insertAt) +
|
|
|
- (content[insertAt - 1] && content[insertAt - 1] !== '\n' ? '\n' : '') +
|
|
|
- paragraphResult +
|
|
|
- (content[insertAt] && content[insertAt] !== '\n' ? '\n' : '') +
|
|
|
- content.slice(insertAt);
|
|
|
- setContent(newContent);
|
|
|
- setParagraphResult(null);
|
|
|
- setShowParagraphPanel(false);
|
|
|
- setParagraphTopic('');
|
|
|
- setParagraphRequirements('');
|
|
|
- }, [paragraphResult, content, cursorPosition]);
|
|
|
-
|
|
|
- const rejectParagraph = useCallback(() => {
|
|
|
- setParagraphResult(null);
|
|
|
- setParagraphError(null);
|
|
|
- }, []);
|
|
|
-
|
|
|
- const generateContent = useCallback(async () => {
|
|
|
- if (!apiSettings) {
|
|
|
- setError('请先配置 API 设置');
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (abortControllerRef.current) {
|
|
|
- abortControllerRef.current.abort();
|
|
|
- }
|
|
|
- abortControllerRef.current = new AbortController();
|
|
|
-
|
|
|
- setIsGenerating(true);
|
|
|
- setError(null);
|
|
|
- setContent('');
|
|
|
- setMainTokenUsage(null);
|
|
|
-
|
|
|
- try {
|
|
|
- const filesResponse = await fetch(
|
|
|
- `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
|
|
|
- );
|
|
|
- const filesData = await filesResponse.json();
|
|
|
-
|
|
|
- const worldStateFiles: WorldStateFile[] = [];
|
|
|
- const loreFiles: WorldLoreFile[] = [];
|
|
|
- if (filesData.success && Array.isArray(filesData.files)) {
|
|
|
- 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 || '',
|
|
|
- rollingSummaries
|
|
|
- );
|
|
|
-
|
|
|
- const prompt = await buildGenerationPrompt(worldContext);
|
|
|
-
|
|
|
- const response = await fetch('/api/stream-proxy', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({
|
|
|
- targetUrl: apiSettings.apiUrl,
|
|
|
- headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
- body: {
|
|
|
- model: apiSettings.model,
|
|
|
- messages: [{ role: 'user', content: prompt }],
|
|
|
- stream: true,
|
|
|
- stream_options: { include_usage: true },
|
|
|
- max_tokens: 4000,
|
|
|
- },
|
|
|
- }),
|
|
|
- signal: abortControllerRef.current.signal,
|
|
|
- });
|
|
|
-
|
|
|
- if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
- if (!response.body) throw new Error('响应体为空');
|
|
|
-
|
|
|
- const reader = response.body.getReader();
|
|
|
- const decoder = new TextDecoder();
|
|
|
- let accumulated = '';
|
|
|
-
|
|
|
- while (true) {
|
|
|
- const { done, value } = await reader.read();
|
|
|
- if (done) break;
|
|
|
-
|
|
|
- const chunk = decoder.decode(value, { stream: true });
|
|
|
- const lines = chunk.split('\n');
|
|
|
-
|
|
|
- for (const line of lines) {
|
|
|
- if (!line.startsWith('data: ')) continue;
|
|
|
- const data = line.slice(6).trim();
|
|
|
- if (data === '[DONE]') break;
|
|
|
-
|
|
|
- try {
|
|
|
- const parsed = JSON.parse(data);
|
|
|
- if (parsed?.usage?.total_tokens) {
|
|
|
- setMainTokenUsage(
|
|
|
- `Token 消耗:输入 ${parsed.usage.prompt_tokens ?? 0} · 输出 ${parsed.usage.completion_tokens ?? 0} · 总计 ${parsed.usage.total_tokens}`
|
|
|
- );
|
|
|
- }
|
|
|
- const delta = parsed.choices?.[0]?.delta?.content;
|
|
|
- if (delta) {
|
|
|
- accumulated += delta;
|
|
|
- setContent(accumulated);
|
|
|
- if (textareaRef.current) {
|
|
|
- textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
|
|
- }
|
|
|
- }
|
|
|
- } catch {
|
|
|
- // 忽略解析错误
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- setHasGenerated(true);
|
|
|
- } catch (err) {
|
|
|
- if (err instanceof Error && err.name === 'AbortError') return;
|
|
|
- setError(err instanceof Error ? err.message : '生成章节内容失败,请重试');
|
|
|
- } finally {
|
|
|
- setIsGenerating(false);
|
|
|
- }
|
|
|
- }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, rollingSummaries]);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- return () => {
|
|
|
- abortControllerRef.current?.abort();
|
|
|
- };
|
|
|
- }, []);
|
|
|
-
|
|
|
- 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);
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="flex flex-col h-full">
|
|
|
- {wordCountWarning && (
|
|
|
- <div className="flex items-center gap-2 p-3 mb-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
|
|
|
- <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
- {wordCountWarning}
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="flex items-center justify-between mb-4">
|
|
|
- <div>
|
|
|
- <h2 className="text-xl font-semibold text-gray-900">生成章节内容</h2>
|
|
|
- <p className="text-sm text-gray-500 mt-1">
|
|
|
- AI 正在根据剧情方向和钩子生成章节,您可以直接编辑修改
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <button
|
|
|
- onClick={handleDeAI}
|
|
|
- disabled={isDeAI || !apiSettings || !content.trim() || isGenerating}
|
|
|
- title="对全文执行三步去AI味修订(去模板化→去专业腔→恢复真实语感)"
|
|
|
- className="flex items-center gap-1.5 px-3 py-2 text-sm bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
- >
|
|
|
- {isDeAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
|
|
|
- 去AI味
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={handlePolish}
|
|
|
- disabled={isPolishing || !apiSettings || selectionStart === selectionEnd || isGenerating}
|
|
|
- title={selectionStart === selectionEnd ? '请先在编辑器中选中文字' : '对选中文字进行润色'}
|
|
|
- className="flex items-center gap-1.5 px-3 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
- >
|
|
|
- {isPolishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
|
- 文本润色
|
|
|
- </button>
|
|
|
-
|
|
|
- <button
|
|
|
- onClick={() => {
|
|
|
- setShowParagraphPanel(true);
|
|
|
- setParagraphResult(null);
|
|
|
- setParagraphError(null);
|
|
|
- }}
|
|
|
- disabled={isGenerating || !apiSettings}
|
|
|
- className="flex items-center gap-1.5 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
- >
|
|
|
- <PlusSquare className="h-4 w-4" />
|
|
|
- 段落生成
|
|
|
- </button>
|
|
|
-
|
|
|
- <button
|
|
|
- onClick={generateContent}
|
|
|
- disabled={isGenerating || !apiSettings}
|
|
|
- className="flex items-center gap-1.5 px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
- >
|
|
|
- {isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
|
- {hasGenerated ? '重新生成' : '生成内容'}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {deAIError && (
|
|
|
- <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
- <AlertCircle className="h-4 w-4 flex-shrink-0" /> 去AI味修订失败: {deAIError}
|
|
|
- <button onClick={() => setDeAIError(null)} className="ml-auto text-red-500 hover:text-red-700">
|
|
|
- <X className="h-4 w-4" /></button></div>
|
|
|
- )}
|
|
|
-
|
|
|
- {polishError && (
|
|
|
- <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
- <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
- {polishError}
|
|
|
- <button onClick={rejectPolish} className="ml-auto text-red-500 hover:text-red-700">
|
|
|
- <X className="h-4 w-4" />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {polishTokenUsage && (
|
|
|
- <p className="text-[11px] text-gray-500 mb-3">{polishTokenUsage}</p>
|
|
|
- )}
|
|
|
-
|
|
|
- {polishResult && (
|
|
|
- <div className="mb-4 border border-amber-200 rounded-xl overflow-hidden bg-amber-50">
|
|
|
- <div className="flex items-center justify-between px-4 py-2 bg-amber-100 border-b border-amber-200">
|
|
|
- <span className="text-sm font-medium text-amber-800">文本润色结果对比</span>
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <button
|
|
|
- onClick={acceptPolish}
|
|
|
- className="flex items-center gap-1 px-3 py-1 text-xs bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
|
|
- >
|
|
|
- <Check className="h-3 w-3" />
|
|
|
- 接受
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={rejectPolish}
|
|
|
- className="flex items-center gap-1 px-3 py-1 text-xs bg-gray-500 text-white rounded-md hover:bg-gray-600 transition-colors"
|
|
|
- >
|
|
|
- <X className="h-3 w-3" />
|
|
|
- 拒绝
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div className="grid grid-cols-2 divide-x divide-amber-200">
|
|
|
- <div className="p-4">
|
|
|
- <p className="text-xs text-amber-600 mb-1">原文</p>
|
|
|
- <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.original}</p>
|
|
|
- </div>
|
|
|
- <div className="p-4">
|
|
|
- <p className="text-xs text-green-600 mb-1">润色后</p>
|
|
|
- <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.polished}</p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {showParagraphPanel && (
|
|
|
- <div className="mb-4 border border-emerald-200 rounded-xl overflow-hidden bg-emerald-50">
|
|
|
- <div className="flex items-center justify-between px-4 py-2 bg-emerald-100 border-b border-emerald-200">
|
|
|
- <span className="text-sm font-medium text-emerald-800">段落生成</span>
|
|
|
- <button onClick={() => setShowParagraphPanel(false)} className="text-emerald-600 hover:text-emerald-800">
|
|
|
- <X className="h-4 w-4" />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <div className="p-4 space-y-3">
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={paragraphTopic}
|
|
|
- onChange={(e) => setParagraphTopic(e.target.value)}
|
|
|
- placeholder="输入段落主题..."
|
|
|
- className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
|
- />
|
|
|
- <textarea
|
|
|
- value={paragraphRequirements}
|
|
|
- onChange={(e) => setParagraphRequirements(e.target.value)}
|
|
|
- placeholder="可选:输入额外要求..."
|
|
|
- className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none h-20"
|
|
|
- />
|
|
|
- {paragraphError && (
|
|
|
- <p className="text-xs text-red-600">{paragraphError}</p>
|
|
|
- )}
|
|
|
- {paragraphTokenUsage && (
|
|
|
- <p className="text-[11px] text-gray-500">{paragraphTokenUsage}</p>
|
|
|
- )}
|
|
|
- <div className="flex gap-2">
|
|
|
- <button
|
|
|
- onClick={handleGenerateParagraph}
|
|
|
- disabled={isGeneratingParagraph || !apiSettings}
|
|
|
- className="flex items-center gap-1.5 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
|
|
- >
|
|
|
- {isGeneratingParagraph ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
|
- 生成段落
|
|
|
- </button>
|
|
|
- {paragraphResult && (
|
|
|
- <button
|
|
|
- onClick={acceptParagraph}
|
|
|
- className="flex items-center gap-1 px-3 py-2 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
|
- >
|
|
|
- <Check className="h-3 w-3" />
|
|
|
- 插入光标位置
|
|
|
- </button>
|
|
|
- )}
|
|
|
- {paragraphResult && (
|
|
|
- <button
|
|
|
- onClick={rejectParagraph}
|
|
|
- className="flex items-center gap-1 px-3 py-2 text-xs bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
|
|
- >
|
|
|
- <X className="h-3 w-3" />
|
|
|
- 取消
|
|
|
- </button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- {paragraphResult && (
|
|
|
- <div className="p-3 bg-white border border-emerald-200 rounded-lg">
|
|
|
- <p className="text-xs text-emerald-600 mb-1">生成的段落</p>
|
|
|
- <p className="text-sm text-gray-700 whitespace-pre-wrap">{paragraphResult}</p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="flex-1 flex flex-col min-h-0 mb-4">
|
|
|
- <div className="flex items-center justify-between mb-2">
|
|
|
- <span className="text-sm font-medium text-gray-700">
|
|
|
- 章节内容 {wordCount} / {wordCountMin}-{wordCountMax} 字
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <textarea
|
|
|
- ref={textareaRef}
|
|
|
- value={content}
|
|
|
- onChange={(e) => setContent(e.target.value)}
|
|
|
- onSelect={(e) => {
|
|
|
- const target = e.target as HTMLTextAreaElement;
|
|
|
- setSelectionStart(target.selectionStart);
|
|
|
- setSelectionEnd(target.selectionEnd);
|
|
|
- }}
|
|
|
- onClick={(e) => {
|
|
|
- const target = e.target as HTMLTextAreaElement;
|
|
|
- setCursorPosition(target.selectionStart);
|
|
|
- }}
|
|
|
- onKeyUp={(e) => {
|
|
|
- const target = e.target as HTMLTextAreaElement;
|
|
|
- setCursorPosition(target.selectionStart);
|
|
|
- }}
|
|
|
- placeholder="点击「生成内容」按钮让 AI 创作本章..."
|
|
|
- className="flex-1 w-full px-4 py-3 text-sm border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500 font-serif leading-relaxed"
|
|
|
- disabled={isGenerating}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- {mainTokenUsage && (
|
|
|
- <p className="text-[11px] text-gray-500 mb-3">{mainTokenUsage}</p>
|
|
|
- )}
|
|
|
-
|
|
|
- {error && (
|
|
|
- <div className="flex items-center gap-2 p-3 mb-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
- <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
- {error}
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="mt-auto pt-4 border-t border-gray-200 flex items-center justify-between">
|
|
|
- <button
|
|
|
- onClick={onBack}
|
|
|
- className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
|
- >
|
|
|
- <ChevronLeft className="h-4 w-4" />
|
|
|
- 上一步:解决钩子
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={handleNext}
|
|
|
- disabled={!content.trim() || isGenerating}
|
|
|
- className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
- >
|
|
|
- 下一步:审核提交
|
|
|
- <ChevronRight className="h-4 w-4" />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
+'use client';
|
|
|
+
|
|
|
+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, EventNode, WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
|
|
|
+import { buildAuthenticityPassPrompt } from '@/app/lib/chapter-prompts';
|
|
|
+
|
|
|
+interface ApiSettings {
|
|
|
+ apiUrl: string;
|
|
|
+ apiKey: string;
|
|
|
+ model: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface Step4ContentEditorProps {
|
|
|
+ projectPath: string;
|
|
|
+ selectedPlot: string;
|
|
|
+ embedHooks: { id: string; type: string; description: string; embedMethod: string }[];
|
|
|
+ manualHooks: string[];
|
|
|
+ resolveHookIds: string[];
|
|
|
+ savedChapterContent: string;
|
|
|
+ apiSettings: ApiSettings | null;
|
|
|
+ writingConstraints: WritingConstraint[];
|
|
|
+ antiHomogenizationStrategies: AntiHomogenizationStrategy[];
|
|
|
+ wordCountMin: number;
|
|
|
+ wordCountMax: number;
|
|
|
+ recentChaptersCount: number;
|
|
|
+ ironRules?: string;
|
|
|
+ onNext: (content: string) => void;
|
|
|
+ onBack: () => void;
|
|
|
+}
|
|
|
+
|
|
|
+const ANTI_HOMOGENIZATION_LABELS: Record<AntiHomogenizationStrategy, string> = {
|
|
|
+ perspective_rotation: '叙事视角轮换',
|
|
|
+ sensory_enhancement: '场景感官强化',
|
|
|
+ authentic_detail: '真实细节注入',
|
|
|
+ rhythm_contrast: '节奏对比',
|
|
|
+ dialogue_personalization: '对话个性化',
|
|
|
+ unexpected_element: '意外元素',
|
|
|
+};
|
|
|
+
|
|
|
+function countChineseWords(text: string): number {
|
|
|
+ const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
|
+ const englishWords = (text.match(/\b[a-zA-Z]+\b/g) || []).length;
|
|
|
+ return chineseChars + englishWords;
|
|
|
+}
|
|
|
+
|
|
|
+function buildLayeredContext(
|
|
|
+ worldStateFiles: WorldStateFile[],
|
|
|
+ loreFiles: WorldLoreFile[],
|
|
|
+ chapterEvents: EventNode[],
|
|
|
+ ironRules: string,
|
|
|
+ rollingSummaries: string[]
|
|
|
+): string {
|
|
|
+ const parts: string[] = [];
|
|
|
+
|
|
|
+ // Layer 1: Always inject (iron-rules, current chapter events)
|
|
|
+ if (ironRules) parts.push(`## \u9879\u76ee\u94c1\u5f8b\n${ironRules}`);
|
|
|
+ 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,
|
|
|
+ embedHooks,
|
|
|
+ manualHooks,
|
|
|
+ resolveHookIds,
|
|
|
+ savedChapterContent,
|
|
|
+ apiSettings,
|
|
|
+ writingConstraints,
|
|
|
+ antiHomogenizationStrategies,
|
|
|
+ wordCountMin,
|
|
|
+ wordCountMax,
|
|
|
+ recentChaptersCount,
|
|
|
+ ironRules,
|
|
|
+ onNext,
|
|
|
+ onBack,
|
|
|
+}: Step4ContentEditorProps) {
|
|
|
+ const [content, setContent] = useState(savedChapterContent);
|
|
|
+ const [isGenerating, setIsGenerating] = useState(false);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+ const [hasGenerated, setHasGenerated] = useState(!!savedChapterContent);
|
|
|
+ const [mainTokenUsage, setMainTokenUsage] = useState<string | null>(null);
|
|
|
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
+ const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
+
|
|
|
+ const [selectionStart, setSelectionStart] = useState(0);
|
|
|
+ const [selectionEnd, setSelectionEnd] = useState(0);
|
|
|
+ const [isPolishing, setIsPolishing] = useState(false);
|
|
|
+ const [polishResult, setPolishResult] = useState<{ original: string; polished: string } | null>(null);
|
|
|
+ const [polishError, setPolishError] = useState<string | null>(null);
|
|
|
+ const [polishTokenUsage, setPolishTokenUsage] = useState<string | null>(null);
|
|
|
+
|
|
|
+ // 真实性修订(去 AI 味)
|
|
|
+ const [isDeAI, setIsDeAI] = useState(false);
|
|
|
+ const [deAIError, setDeAIError] = useState<string | null>(null);
|
|
|
+
|
|
|
+ const [showParagraphPanel, setShowParagraphPanel] = useState(false);
|
|
|
+ const [paragraphTopic, setParagraphTopic] = useState('');
|
|
|
+ const [paragraphRequirements, setParagraphRequirements] = useState('');
|
|
|
+ const [isGeneratingParagraph, setIsGeneratingParagraph] = useState(false);
|
|
|
+ const [paragraphResult, setParagraphResult] = useState<string | null>(null);
|
|
|
+ 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;
|
|
|
+ const wordCountWarning =
|
|
|
+ content.length > 0 && !isWordCountInRange
|
|
|
+ ? wordCount < wordCountMin
|
|
|
+ ? `字数不足:当前 ${wordCount} 字,目标最少 ${wordCountMin} 字`
|
|
|
+ : `字数超出:当前 ${wordCount} 字,目标最多 ${wordCountMax} 字`
|
|
|
+ : null;
|
|
|
+
|
|
|
+ const buildGenerationPrompt = useCallback(
|
|
|
+ async (worldContext: string): Promise<string> => {
|
|
|
+ const enabledConstraints = writingConstraints
|
|
|
+ .filter((c) => c.enabled)
|
|
|
+ .map((c) => `- ${c.label}`)
|
|
|
+ .join('\n');
|
|
|
+
|
|
|
+ const antiStrategies = antiHomogenizationStrategies
|
|
|
+ .map((s) => `- ${ANTI_HOMOGENIZATION_LABELS[s]}`)
|
|
|
+ .join('\n');
|
|
|
+
|
|
|
+ const embedHooksText = embedHooks.length > 0
|
|
|
+ ? `\n本章需要埋入的叙事钩子:\n${embedHooks.map((h) => `- [${h.type}] ${h.description}(埋入方式:${h.embedMethod})`).join('\n')}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const manualHooksText = manualHooks.length > 0
|
|
|
+ ? `\n手动添加的叙事钩子:\n${manualHooks.map((h) => `- ${h}`).join('\n')}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const resolveHooksText = resolveHookIds.length > 0
|
|
|
+ ? `\n本章需要解决的历史钩子 ID:\n${resolveHookIds.map((id) => `- ${id}`).join('\n')}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const hooksText = embedHooksText + manualHooksText + resolveHooksText;
|
|
|
+
|
|
|
+ const constraintsText = enabledConstraints
|
|
|
+ ? `\n写作约束(必须满足):\n${enabledConstraints}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const antiText = antiStrategies
|
|
|
+ ? `\n反同质化策略(请在章节中体现):\n${antiStrategies}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ // Fetch style guide and interview files for injection
|
|
|
+ let contextText = '';
|
|
|
+ try {
|
|
|
+ const styleRes = await fetch(
|
|
|
+ `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
|
|
|
+ );
|
|
|
+ const styleData = await styleRes.json();
|
|
|
+ if (styleData.success && Array.isArray(styleData.files)) {
|
|
|
+ const styleFile = styleData.files.find(
|
|
|
+ (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
|
|
|
+ );
|
|
|
+ if (styleFile?.content) contextText += `\n【文风设定】\n${styleFile.content}\n`;
|
|
|
+ }
|
|
|
+ } catch { /* ignore */ }
|
|
|
+
|
|
|
+ // Fetch interview project files for context
|
|
|
+ const interviewFiles = ['00-project-overview.md', '01-theme-and-proposition.md', '03-cast-bible.md'];
|
|
|
+ for (const fileName of interviewFiles) {
|
|
|
+ try {
|
|
|
+ const res = await fetch('/api/novel/files', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ action: 'read', projectPath, filePath: fileName }),
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ const data = await res.json();
|
|
|
+ if (data.content) contextText += `\n【${fileName.replace('.md', '')}】\n${data.content}\n`;
|
|
|
+ }
|
|
|
+ } catch { /* file may not exist */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const antiDriftSection = ironRules ? `## 项目铁律\n${ironRules}` : '';
|
|
|
+
|
|
|
+ return `${worldContext}${contextText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
|
|
|
+
|
|
|
+请根据以下要求创作本章完整内容:
|
|
|
+
|
|
|
+【剧情方向】
|
|
|
+${selectedPlot}
|
|
|
+${hooksText}
|
|
|
+${constraintsText}
|
|
|
+${antiText}
|
|
|
+
|
|
|
+【字数要求】
|
|
|
+本章字数需在 ${wordCountMin} 到 ${wordCountMax} 字之间。
|
|
|
+
|
|
|
+【创作要求】
|
|
|
+1. 严格遵循上方世界状态和文风设定
|
|
|
+2. 完整呈现选定的剧情方向
|
|
|
+3. 自然地埋入所有选定的叙事钩子
|
|
|
+4. 如果有需要解决的历史钩子,在本章中合理地揭示或解决
|
|
|
+5. 满足所有写作约束
|
|
|
+6. 体现反同质化策略,避免风格同质化
|
|
|
+7. 直接输出章节正文,不要包含标题或额外说明
|
|
|
+
|
|
|
+请开始创作:`;
|
|
|
+ },
|
|
|
+ [
|
|
|
+ projectPath,
|
|
|
+ selectedPlot,
|
|
|
+ embedHooks,
|
|
|
+ manualHooks,
|
|
|
+ resolveHookIds,
|
|
|
+ writingConstraints,
|
|
|
+ antiHomogenizationStrategies,
|
|
|
+ wordCountMin,
|
|
|
+ wordCountMax,
|
|
|
+ ironRules,
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ const fetchWritingStyle = useCallback(async (): Promise<string> => {
|
|
|
+ try {
|
|
|
+ const res = await fetch(
|
|
|
+ `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=world_state`
|
|
|
+ );
|
|
|
+ const data = await res.json();
|
|
|
+ if (data.success && Array.isArray(data.files)) {
|
|
|
+ const styleFile = data.files.find(
|
|
|
+ (f: { fileName: string; content: string }) => f.fileName === 'writing_style.md'
|
|
|
+ );
|
|
|
+ return styleFile?.content?.trim() ?? '';
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 忽略错误
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }, [projectPath]);
|
|
|
+
|
|
|
+ const handlePolish = useCallback(async () => {
|
|
|
+ if (!apiSettings) {
|
|
|
+ setPolishError('请先配置 API 设置');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const selectedText = content.slice(selectionStart, selectionEnd);
|
|
|
+ if (!selectedText.trim()) return;
|
|
|
+
|
|
|
+ setIsPolishing(true);
|
|
|
+ setPolishError(null);
|
|
|
+ setPolishResult(null);
|
|
|
+ setPolishTokenUsage(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const writingStyle = await fetchWritingStyle();
|
|
|
+ const styleSection = writingStyle
|
|
|
+ ? `文风参考:\n${writingStyle}\n\n`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const prompt = `请对以下文字进行润色,保持原意不变,但提升文学性和可读性。\n\n${styleSection}原文:\n${selectedText}\n\n请直接返回润色后的文字,不要包含任何解释。`;
|
|
|
+
|
|
|
+ const response = await fetch('/api/proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ targetUrl: apiSettings.apiUrl,
|
|
|
+ headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
+ body: {
|
|
|
+ model: apiSettings.model,
|
|
|
+ messages: [{ role: 'user', content: prompt }],
|
|
|
+ stream: false,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
+ const data = await response.json();
|
|
|
+ const polished =
|
|
|
+ data?.choices?.[0]?.message?.content?.trim() ??
|
|
|
+ data?.choices?.[0]?.text?.trim() ??
|
|
|
+ '';
|
|
|
+
|
|
|
+ if (!polished) throw new Error('AI 返回内容为空');
|
|
|
+ setPolishResult({ original: selectedText, polished });
|
|
|
+ if (data?.usage?.total_tokens) {
|
|
|
+ setPolishTokenUsage(
|
|
|
+ `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ setPolishError(err instanceof Error ? err.message : '润色失败,请重试');
|
|
|
+ } finally {
|
|
|
+ setIsPolishing(false);
|
|
|
+ }
|
|
|
+ }, [apiSettings, content, selectionStart, selectionEnd, fetchWritingStyle]);
|
|
|
+
|
|
|
+ const acceptPolish = useCallback(() => {
|
|
|
+ if (!polishResult) return;
|
|
|
+ const newContent =
|
|
|
+ content.slice(0, selectionStart) +
|
|
|
+ polishResult.polished +
|
|
|
+ content.slice(selectionEnd);
|
|
|
+ setContent(newContent);
|
|
|
+ setPolishResult(null);
|
|
|
+ setSelectionStart(0);
|
|
|
+ setSelectionEnd(0);
|
|
|
+ }, [polishResult, content, selectionStart, selectionEnd]);
|
|
|
+
|
|
|
+ const rejectPolish = useCallback(() => {
|
|
|
+ setPolishResult(null);
|
|
|
+ setPolishError(null);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 真实性修订(去 AI 味)- 对全文进行三步修订
|
|
|
+ const handleDeAI = useCallback(async () => {
|
|
|
+ if (!content.trim() || !apiSettings) return;
|
|
|
+ setIsDeAI(true);
|
|
|
+ setDeAIError(null);
|
|
|
+ try {
|
|
|
+ const prompt = buildAuthenticityPassPrompt(content, '');
|
|
|
+ const requestBody = {
|
|
|
+ model: apiSettings.model,
|
|
|
+ messages: [
|
|
|
+ { role: 'system' as const, content: '你是小说文字编辑,擅长去除AI痕迹、恢复真实语感。用中文输出。' },
|
|
|
+ { role: 'user' as const, content: prompt },
|
|
|
+ ],
|
|
|
+ temperature: 0.4,
|
|
|
+ stream: false,
|
|
|
+ };
|
|
|
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
|
+ if (apiSettings.apiKey) headers['Authorization'] = `Bearer ${apiSettings.apiKey}`;
|
|
|
+ const res = await fetch('/api/proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ targetUrl: apiSettings.apiUrl,
|
|
|
+ headers,
|
|
|
+ body: requestBody,
|
|
|
+ isOllama: apiSettings.apiUrl.includes('11434'),
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
|
+ const data = await res.json();
|
|
|
+ const deAIContent = data.choices?.[0]?.message?.content;
|
|
|
+ if (deAIContent) { setContent(deAIContent); }
|
|
|
+ } catch (err) {
|
|
|
+ setDeAIError(err instanceof Error ? err.message : '修订失败');
|
|
|
+ } finally {
|
|
|
+ setIsDeAI(false);
|
|
|
+ }
|
|
|
+ }, [content, apiSettings]);
|
|
|
+
|
|
|
+ const handleGenerateParagraph = useCallback(async () => {
|
|
|
+ if (!apiSettings) {
|
|
|
+ setParagraphError('请先配置 API 设置');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!paragraphTopic.trim()) {
|
|
|
+ setParagraphError('请输入段落主题');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsGeneratingParagraph(true);
|
|
|
+ setParagraphError(null);
|
|
|
+ setParagraphResult(null);
|
|
|
+ setParagraphTokenUsage(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const filesRes = await fetch(
|
|
|
+ `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
|
|
|
+ );
|
|
|
+ const filesData = await filesRes.json();
|
|
|
+
|
|
|
+ const worldStateFiles: WorldStateFile[] = [];
|
|
|
+ const loreFiles: WorldLoreFile[] = [];
|
|
|
+ if (filesData.success && Array.isArray(filesData.files)) {
|
|
|
+ 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 || '',
|
|
|
+ rollingSummaries
|
|
|
+ );
|
|
|
+
|
|
|
+ const requirementsSection = paragraphRequirements.trim()
|
|
|
+ ? `要求:${paragraphRequirements.trim()}\n`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const prompt = `请根据以下要求生成一段小说内容,插入到当前章节中。\n\n${worldContext}\n\n段落主题:${paragraphTopic.trim()}\n${requirementsSection}\n请直接返回生成的段落内容,不要包含任何解释。`;
|
|
|
+
|
|
|
+ const response = await fetch('/api/proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ targetUrl: apiSettings.apiUrl,
|
|
|
+ headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
+ body: {
|
|
|
+ model: apiSettings.model,
|
|
|
+ messages: [{ role: 'user', content: prompt }],
|
|
|
+ stream: false,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
+ const data = await response.json();
|
|
|
+ const generated =
|
|
|
+ data?.choices?.[0]?.message?.content?.trim() ??
|
|
|
+ data?.choices?.[0]?.text?.trim() ??
|
|
|
+ '';
|
|
|
+
|
|
|
+ if (!generated) throw new Error('AI 返回内容为空');
|
|
|
+ setParagraphResult(generated);
|
|
|
+ if (data?.usage?.total_tokens) {
|
|
|
+ setParagraphTokenUsage(
|
|
|
+ `Token 消耗:输入 ${data.usage.prompt_tokens ?? 0} · 输出 ${data.usage.completion_tokens ?? 0} · 总计 ${data.usage.total_tokens}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ setParagraphError(err instanceof Error ? err.message : '段落生成失败,请重试');
|
|
|
+ } finally {
|
|
|
+ setIsGeneratingParagraph(false);
|
|
|
+ }
|
|
|
+ }, [apiSettings, projectPath, paragraphTopic, paragraphRequirements, ironRules, rollingSummaries]);
|
|
|
+
|
|
|
+ const acceptParagraph = useCallback(() => {
|
|
|
+ if (!paragraphResult) return;
|
|
|
+ const insertAt = cursorPosition;
|
|
|
+ const newContent =
|
|
|
+ content.slice(0, insertAt) +
|
|
|
+ (content[insertAt - 1] && content[insertAt - 1] !== '\n' ? '\n' : '') +
|
|
|
+ paragraphResult +
|
|
|
+ (content[insertAt] && content[insertAt] !== '\n' ? '\n' : '') +
|
|
|
+ content.slice(insertAt);
|
|
|
+ setContent(newContent);
|
|
|
+ setParagraphResult(null);
|
|
|
+ setShowParagraphPanel(false);
|
|
|
+ setParagraphTopic('');
|
|
|
+ setParagraphRequirements('');
|
|
|
+ }, [paragraphResult, content, cursorPosition]);
|
|
|
+
|
|
|
+ const rejectParagraph = useCallback(() => {
|
|
|
+ setParagraphResult(null);
|
|
|
+ setParagraphError(null);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const generateContent = useCallback(async () => {
|
|
|
+ if (!apiSettings) {
|
|
|
+ setError('请先配置 API 设置');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (abortControllerRef.current) {
|
|
|
+ abortControllerRef.current.abort();
|
|
|
+ }
|
|
|
+ abortControllerRef.current = new AbortController();
|
|
|
+
|
|
|
+ setIsGenerating(true);
|
|
|
+ setError(null);
|
|
|
+ setContent('');
|
|
|
+ setMainTokenUsage(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const filesResponse = await fetch(
|
|
|
+ `/api/novel/files?projectPath=${encodeURIComponent(projectPath)}&type=all`
|
|
|
+ );
|
|
|
+ const filesData = await filesResponse.json();
|
|
|
+
|
|
|
+ const worldStateFiles: WorldStateFile[] = [];
|
|
|
+ const loreFiles: WorldLoreFile[] = [];
|
|
|
+ if (filesData.success && Array.isArray(filesData.files)) {
|
|
|
+ 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 || '',
|
|
|
+ rollingSummaries
|
|
|
+ );
|
|
|
+
|
|
|
+ const prompt = await buildGenerationPrompt(worldContext);
|
|
|
+
|
|
|
+ const response = await fetch('/api/stream-proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ targetUrl: apiSettings.apiUrl,
|
|
|
+ headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
+ body: {
|
|
|
+ model: apiSettings.model,
|
|
|
+ messages: [{ role: 'user', content: prompt }],
|
|
|
+ stream: true,
|
|
|
+ stream_options: { include_usage: true },
|
|
|
+ max_tokens: 4000,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ signal: abortControllerRef.current.signal,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) throw new Error(`AI 请求失败:HTTP ${response.status}`);
|
|
|
+ if (!response.body) throw new Error('响应体为空');
|
|
|
+
|
|
|
+ const reader = response.body.getReader();
|
|
|
+ const decoder = new TextDecoder();
|
|
|
+ let accumulated = '';
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const { done, value } = await reader.read();
|
|
|
+ if (done) break;
|
|
|
+
|
|
|
+ const chunk = decoder.decode(value, { stream: true });
|
|
|
+ const lines = chunk.split('\n');
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!line.startsWith('data: ')) continue;
|
|
|
+ const data = line.slice(6).trim();
|
|
|
+ if (data === '[DONE]') break;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(data);
|
|
|
+ if (parsed?.usage?.total_tokens) {
|
|
|
+ setMainTokenUsage(
|
|
|
+ `Token 消耗:输入 ${parsed.usage.prompt_tokens ?? 0} · 输出 ${parsed.usage.completion_tokens ?? 0} · 总计 ${parsed.usage.total_tokens}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ const delta = parsed.choices?.[0]?.delta?.content;
|
|
|
+ if (delta) {
|
|
|
+ accumulated += delta;
|
|
|
+ setContent(accumulated);
|
|
|
+ if (textareaRef.current) {
|
|
|
+ textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 忽略解析错误
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setHasGenerated(true);
|
|
|
+ } catch (err) {
|
|
|
+ if (err instanceof Error && err.name === 'AbortError') return;
|
|
|
+ setError(err instanceof Error ? err.message : '生成章节内容失败,请重试');
|
|
|
+ } finally {
|
|
|
+ setIsGenerating(false);
|
|
|
+ }
|
|
|
+ }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, rollingSummaries]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ abortControllerRef.current?.abort();
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ 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);
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col h-full">
|
|
|
+ {wordCountWarning && (
|
|
|
+ <div className="flex items-center gap-2 p-3 mb-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
|
|
|
+ <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
+ {wordCountWarning}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
+ <div>
|
|
|
+ <h2 className="text-xl font-semibold text-gray-900">生成章节内容</h2>
|
|
|
+ <p className="text-sm text-gray-500 mt-1">
|
|
|
+ AI 正在根据剧情方向和钩子生成章节,您可以直接编辑修改
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={handleDeAI}
|
|
|
+ disabled={isDeAI || !apiSettings || !content.trim() || isGenerating}
|
|
|
+ title="对全文执行三步去AI味修订(去模板化→去专业腔→恢复真实语感)"
|
|
|
+ className="flex items-center gap-1.5 px-3 py-2 text-sm bg-purple-500 text-white rounded-lg hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ {isDeAI ? <Loader2 className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
|
|
|
+ 去AI味
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={handlePolish}
|
|
|
+ disabled={isPolishing || !apiSettings || selectionStart === selectionEnd || isGenerating}
|
|
|
+ title={selectionStart === selectionEnd ? '请先在编辑器中选中文字' : '对选中文字进行润色'}
|
|
|
+ className="flex items-center gap-1.5 px-3 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ {isPolishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
|
+ 文本润色
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setShowParagraphPanel(true);
|
|
|
+ setParagraphResult(null);
|
|
|
+ setParagraphError(null);
|
|
|
+ }}
|
|
|
+ disabled={isGenerating || !apiSettings}
|
|
|
+ className="flex items-center gap-1.5 px-3 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ <PlusSquare className="h-4 w-4" />
|
|
|
+ 段落生成
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={generateContent}
|
|
|
+ disabled={isGenerating || !apiSettings}
|
|
|
+ className="flex items-center gap-1.5 px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ {isGenerating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
|
+ {hasGenerated ? '重新生成' : '生成内容'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {deAIError && (
|
|
|
+ <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
+ <AlertCircle className="h-4 w-4 flex-shrink-0" /> 去AI味修订失败: {deAIError}
|
|
|
+ <button onClick={() => setDeAIError(null)} className="ml-auto text-red-500 hover:text-red-700">
|
|
|
+ <X className="h-4 w-4" /></button></div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {polishError && (
|
|
|
+ <div className="flex items-center gap-2 p-3 mb-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
+ <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
+ {polishError}
|
|
|
+ <button onClick={rejectPolish} className="ml-auto text-red-500 hover:text-red-700">
|
|
|
+ <X className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {polishTokenUsage && (
|
|
|
+ <p className="text-[11px] text-gray-500 mb-3">{polishTokenUsage}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {polishResult && (
|
|
|
+ <div className="mb-4 border border-amber-200 rounded-xl overflow-hidden bg-amber-50">
|
|
|
+ <div className="flex items-center justify-between px-4 py-2 bg-amber-100 border-b border-amber-200">
|
|
|
+ <span className="text-sm font-medium text-amber-800">文本润色结果对比</span>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={acceptPolish}
|
|
|
+ className="flex items-center gap-1 px-3 py-1 text-xs bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
|
|
+ >
|
|
|
+ <Check className="h-3 w-3" />
|
|
|
+ 接受
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={rejectPolish}
|
|
|
+ className="flex items-center gap-1 px-3 py-1 text-xs bg-gray-500 text-white rounded-md hover:bg-gray-600 transition-colors"
|
|
|
+ >
|
|
|
+ <X className="h-3 w-3" />
|
|
|
+ 拒绝
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="grid grid-cols-2 divide-x divide-amber-200">
|
|
|
+ <div className="p-4">
|
|
|
+ <p className="text-xs text-amber-600 mb-1">原文</p>
|
|
|
+ <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.original}</p>
|
|
|
+ </div>
|
|
|
+ <div className="p-4">
|
|
|
+ <p className="text-xs text-green-600 mb-1">润色后</p>
|
|
|
+ <p className="text-sm text-gray-700 whitespace-pre-wrap">{polishResult.polished}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {showParagraphPanel && (
|
|
|
+ <div className="mb-4 border border-emerald-200 rounded-xl overflow-hidden bg-emerald-50">
|
|
|
+ <div className="flex items-center justify-between px-4 py-2 bg-emerald-100 border-b border-emerald-200">
|
|
|
+ <span className="text-sm font-medium text-emerald-800">段落生成</span>
|
|
|
+ <button onClick={() => setShowParagraphPanel(false)} className="text-emerald-600 hover:text-emerald-800">
|
|
|
+ <X className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className="p-4 space-y-3">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={paragraphTopic}
|
|
|
+ onChange={(e) => setParagraphTopic(e.target.value)}
|
|
|
+ placeholder="输入段落主题..."
|
|
|
+ className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
|
+ />
|
|
|
+ <textarea
|
|
|
+ value={paragraphRequirements}
|
|
|
+ onChange={(e) => setParagraphRequirements(e.target.value)}
|
|
|
+ placeholder="可选:输入额外要求..."
|
|
|
+ className="w-full px-3 py-2 text-sm border border-emerald-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none h-20"
|
|
|
+ />
|
|
|
+ {paragraphError && (
|
|
|
+ <p className="text-xs text-red-600">{paragraphError}</p>
|
|
|
+ )}
|
|
|
+ {paragraphTokenUsage && (
|
|
|
+ <p className="text-[11px] text-gray-500">{paragraphTokenUsage}</p>
|
|
|
+ )}
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <button
|
|
|
+ onClick={handleGenerateParagraph}
|
|
|
+ disabled={isGeneratingParagraph || !apiSettings}
|
|
|
+ className="flex items-center gap-1.5 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
|
|
+ >
|
|
|
+ {isGeneratingParagraph ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
|
+ 生成段落
|
|
|
+ </button>
|
|
|
+ {paragraphResult && (
|
|
|
+ <button
|
|
|
+ onClick={acceptParagraph}
|
|
|
+ className="flex items-center gap-1 px-3 py-2 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
|
+ >
|
|
|
+ <Check className="h-3 w-3" />
|
|
|
+ 插入光标位置
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {paragraphResult && (
|
|
|
+ <button
|
|
|
+ onClick={rejectParagraph}
|
|
|
+ className="flex items-center gap-1 px-3 py-2 text-xs bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
|
|
+ >
|
|
|
+ <X className="h-3 w-3" />
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {paragraphResult && (
|
|
|
+ <div className="p-3 bg-white border border-emerald-200 rounded-lg">
|
|
|
+ <p className="text-xs text-emerald-600 mb-1">生成的段落</p>
|
|
|
+ <p className="text-sm text-gray-700 whitespace-pre-wrap">{paragraphResult}</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="flex-1 flex flex-col min-h-0 mb-4">
|
|
|
+ <div className="flex items-center justify-between mb-2">
|
|
|
+ <span className="text-sm font-medium text-gray-700">
|
|
|
+ 章节内容 {wordCount} / {wordCountMin}-{wordCountMax} 字
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <textarea
|
|
|
+ ref={textareaRef}
|
|
|
+ value={content}
|
|
|
+ onChange={(e) => setContent(e.target.value)}
|
|
|
+ onSelect={(e) => {
|
|
|
+ const target = e.target as HTMLTextAreaElement;
|
|
|
+ setSelectionStart(target.selectionStart);
|
|
|
+ setSelectionEnd(target.selectionEnd);
|
|
|
+ }}
|
|
|
+ onClick={(e) => {
|
|
|
+ const target = e.target as HTMLTextAreaElement;
|
|
|
+ setCursorPosition(target.selectionStart);
|
|
|
+ }}
|
|
|
+ onKeyUp={(e) => {
|
|
|
+ const target = e.target as HTMLTextAreaElement;
|
|
|
+ setCursorPosition(target.selectionStart);
|
|
|
+ }}
|
|
|
+ placeholder="点击「生成内容」按钮让 AI 创作本章..."
|
|
|
+ className="flex-1 w-full px-4 py-3 text-sm border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500 font-serif leading-relaxed"
|
|
|
+ disabled={isGenerating}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {mainTokenUsage && (
|
|
|
+ <p className="text-[11px] text-gray-500 mb-3">{mainTokenUsage}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {error && (
|
|
|
+ <div className="flex items-center gap-2 p-3 mb-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
|
+ <AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
|
+ {error}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="mt-auto pt-4 border-t border-gray-200 flex items-center justify-between">
|
|
|
+ <button
|
|
|
+ onClick={onBack}
|
|
|
+ className="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
|
+ >
|
|
|
+ <ChevronLeft className="h-4 w-4" />
|
|
|
+ 上一步:解决钩子
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={handleNext}
|
|
|
+ disabled={!content.trim() || isGenerating}
|
|
|
+ className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ 下一步:审核提交
|
|
|
+ <ChevronRight className="h-4 w-4" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
}
|