Browse Source

fix: Step4 no auto-gen, Step7 skip non-trigger chapters, remove dead code, fix init self-HTTP, fix JSDoc

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 months ago
parent
commit
130e15ff07

+ 122 - 46
src/app/api/novel/init/route.ts

@@ -4,6 +4,7 @@ import path from 'path';
 import { NovelType, DEFAULT_WORLD_STATE_FILES, DEFAULT_LORE_FILES } from '@/app/lib/novel-types';
 import { getDefaultTemplate, getNovelTypeTemplate } from '@/app/lib/novel-utils';
 import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { buildIronRulesPrompt, buildStyleAnchorPrompt } from '@/app/lib/chapter-prompts';
 
 interface InitRequest {
   projectPath: string;
@@ -109,6 +110,100 @@ async function atomicWrite(filePath: string, content: string): Promise<void> {
   await fs.rename(tmpPath, filePath);
 }
 
+/**
+ * 将 AI 返回的 iron-rules JSON 数组格式化为 Markdown
+ */
+function formatIronRulesToMarkdown(aiResponse: string): string {
+  try {
+    const rules = JSON.parse(aiResponse);
+    if (!Array.isArray(rules)) return aiResponse;
+    const categorized: Record<string, string[]> = {};
+    for (const rule of rules) {
+      const cat = rule.category || 'other';
+      if (!categorized[cat]) categorized[cat] = [];
+      categorized[cat].push(`- ${rule.rule}`);
+    }
+    const lines = ['# 项目铁律', ''];
+    for (const [cat, rules] of Object.entries(categorized)) {
+      lines.push(`## ${cat}`);
+      lines.push(...rules);
+      lines.push('');
+    }
+    return lines.join('\n');
+  } catch {
+    return aiResponse;
+  }
+}
+
+/**
+ * 将 AI 返回的 style-anchor JSON 格式化为 Markdown
+ */
+function formatStyleAnchorToMarkdown(aiResponse: string): string {
+  try {
+    const data = JSON.parse(aiResponse);
+    const lines = ['# 风格锚定', ''];
+    if (data.positiveSamples) {
+      lines.push('## 正面样本');
+      for (const sample of data.positiveSamples) {
+        lines.push(sample.content);
+        lines.push(`> 风格特征:${sample.annotation}`);
+        lines.push('');
+      }
+    }
+    if (data.negativeSamples) {
+      lines.push('## 负面样本');
+      for (const sample of data.negativeSamples) {
+        lines.push(sample.content);
+        lines.push(`> AI腔特征:${sample.annotation}`);
+        lines.push('');
+      }
+    }
+    if (data.bannedPhrases?.length) {
+      lines.push('## 禁用词');
+      for (const phrase of data.bannedPhrases) {
+        lines.push(`- ${phrase}`);
+      }
+      lines.push('');
+    }
+    if (data.bannedPatterns?.length) {
+      lines.push('## 禁用句式');
+      for (const pattern of data.bannedPatterns) {
+        lines.push(`- ${pattern}`);
+      }
+      lines.push('');
+    }
+    return lines.join('\n');
+  } catch {
+    return aiResponse;
+  }
+}
+
+/**
+ * 调用 AI API 并返回响应文本
+ */
+async function callAI(prompt: string, apiSettings: { apiUrl: string; apiKey: string; model: string }, maxTokens = 3000): Promise<string> {
+  const response = await fetch(apiSettings.apiUrl, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${apiSettings.apiKey}`,
+    },
+    body: JSON.stringify({
+      model: apiSettings.model,
+      messages: [{ role: 'user', content: prompt }],
+      max_tokens: maxTokens,
+    }),
+    signal: AbortSignal.timeout(60000),
+  });
+  if (!response.ok) throw new Error(`AI request failed with status ${response.status}`);
+  const data = await response.json();
+  const content = data.choices?.[0]?.message?.content;
+  if (!content || typeof content !== 'string' || content.trim() === '') {
+    throw new Error('AI returned empty content');
+  }
+  return content.trim();
+}
+
 export async function POST(request: NextRequest) {
   let body: InitRequest;
 
@@ -199,67 +294,48 @@ export async function POST(request: NextRequest) {
       }
     }
 
-    // 3.3b Generate iron-rules.md via the dedicated API
+    // 3.3b Generate iron-rules.md via direct AI call
     if (body.generateIronRules && apiSettings && worldDescription) {
       const ironRulesPath = path.join(projectPath, 'iron-rules.md');
       try {
-        const origin = request.headers.get('origin') || `http://localhost:${process.env.PORT || 3000}`;
-        const ironRulesRes = await fetch(`${origin}/api/novel/iron-rules`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            projectInfo: {
-              novelType,
-              worldDescription: worldDescription || '',
-              protagonistDescription: body.interviewData?.characters || '',
-              plotDirection: body.interviewData?.plotDirection || '',
-            },
-            apiSettings,
-          }),
+        const prompt = buildIronRulesPrompt({
+          novelType,
+          worldDescription: worldDescription || '',
+          protagonistDescription: body.interviewData?.characters || '',
+          plotDirection: body.interviewData?.plotDirection || '',
         });
-        const ironRulesData = await ironRulesRes.json();
-        if (ironRulesData.success) {
-          // Replace in filesToWrite or add new
-          const existingIdx = filesToWrite.findIndex(f => f.filePath === ironRulesPath);
-          if (existingIdx >= 0) {
-            filesToWrite[existingIdx].content = ironRulesData.markdown;
-          } else {
-            filesToWrite.push({ filePath: ironRulesPath, content: ironRulesData.markdown });
-          }
-          generatedFiles.push('iron-rules.md');
+        const content = await callAI(prompt, apiSettings, 2000);
+        const markdown = formatIronRulesToMarkdown(content);
+        const existingIdx = filesToWrite.findIndex(f => f.filePath === ironRulesPath);
+        if (existingIdx >= 0) {
+          filesToWrite[existingIdx].content = markdown;
+        } else {
+          filesToWrite.push({ filePath: ironRulesPath, content: markdown });
         }
+        generatedFiles.push('iron-rules.md');
       } catch {
         failedFiles.push('iron-rules.md');
       }
     }
 
-    // 3.3c Generate style-anchor.md via the dedicated API
+    // 3.3c Generate style-anchor.md via direct AI call
     if (body.generateStyleAnchor && apiSettings && body.styleInfo) {
       const styleAnchorPath = path.join(projectPath, 'style-anchor.md');
       try {
-        const origin = request.headers.get('origin') || `http://localhost:${process.env.PORT || 3000}`;
-        const styleAnchorRes = await fetch(`${origin}/api/novel/style-anchor`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            styleInfo: {
-              ...body.styleInfo,
-              novelType,
-              worldDescription: worldDescription || '',
-            },
-            apiSettings,
-          }),
+        const prompt = buildStyleAnchorPrompt({
+          ...body.styleInfo,
+          novelType,
+          worldDescription: worldDescription || '',
         });
-        const styleAnchorData = await styleAnchorRes.json();
-        if (styleAnchorData.success) {
-          const existingIdx = filesToWrite.findIndex(f => f.filePath === styleAnchorPath);
-          if (existingIdx >= 0) {
-            filesToWrite[existingIdx].content = styleAnchorData.markdown;
-          } else {
-            filesToWrite.push({ filePath: styleAnchorPath, content: styleAnchorData.markdown });
-          }
-          generatedFiles.push('style-anchor.md');
+        const content = await callAI(prompt, apiSettings, 3000);
+        const markdown = formatStyleAnchorToMarkdown(content);
+        const existingIdx = filesToWrite.findIndex(f => f.filePath === styleAnchorPath);
+        if (existingIdx >= 0) {
+          filesToWrite[existingIdx].content = markdown;
+        } else {
+          filesToWrite.push({ filePath: styleAnchorPath, content: markdown });
         }
+        generatedFiles.push('style-anchor.md');
       } catch {
         failedFiles.push('style-anchor.md');
       }

+ 9 - 3
src/app/features/novel-writer/components/ChapterDraftPipeline.tsx

@@ -227,10 +227,16 @@ export default function ChapterDraftPipeline({
     []
   );
 
-  // Step 6 完成 → Step 7 读者反馈
+  // Step 6 完成 → 条件性进入 Step 7(每5章一次)或直接完成
   const handleStep6Complete = useCallback(() => {
-    setCurrentStep(7);
-  }, []);
+    if (chapterIndex % 5 === 0) {
+      setCurrentStep(7);
+    } else {
+      sessionStorage.removeItem(storageKey);
+      const title = pipelineState.controlCard?.workingTitle || `第${chapterIndex}章`;
+      onComplete(title, pipelineState.chapterContent);
+    }
+  }, [onComplete, pipelineState.chapterContent, pipelineState.controlCard, chapterIndex, storageKey]);
 
   // Step 7 完成 → 最终提交
   const handleStep7Complete = useCallback(() => {

+ 1 - 22
src/app/features/novel-writer/components/InitWizard.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import { useState, useCallback } from 'react';
-import { DEFAULT_LORE_FILES, DEFAULT_WORLD_STATE_FILES, NovelType } from '@/app/lib/novel-types';
+import { NovelType } from '@/app/lib/novel-types';
 import { STYLE_MODULES, getStyleCorePrompt } from '@/app/lib/style-modules';
 import { INTERVIEW_QUESTIONS } from '@/app/lib/chapter-prompts';
 
@@ -46,17 +46,6 @@ const STYLE_PRESETS_WITH_MODULES = [
   { label: '黑暗沉重', description: '世界观压抑,人性复杂,命运无常', styleId: 'horror' as const },
 ];
 
-const LORE_FILES = [
-  { key: 'worldbuilding.md', label: '世界观总纲' },
-  { key: 'geography.md', label: '地理信息' },
-  { key: 'factions.md', label: '势力与组织' },
-  { key: 'history.md', label: '历史事件' },
-  { key: 'culture.md', label: '文化习俗' },
-  { key: 'economy.md', label: '经济体系' },
-  { key: 'characters_lore.md', label: '重要人物志' },
-  { key: 'writing_style.md', label: '文风档案' },
-];
-
 const DISABLABLE_WORLD_STATE_FILES: { key: string; label: string }[] = [
   { key: 'plot_summary.md', label: '剧情梗概' },
   { key: 'protagonist_stats.md', label: '主角属性' },
@@ -79,13 +68,6 @@ const DISABLABLE_LORE_FILES: { key: string; label: string }[] = [
 
 type FileStatus = 'pending' | 'generating' | 'done' | 'error';
 
-interface FileGenerationState {
-  key: string;
-  label: string;
-  status: FileStatus;
-  error?: string;
-}
-
 // ─── Step Indicator ───────────────────────────────────────────────────────────
 
 const STEPS = ['选择类型', '角色设定', '情节方向', '世界观描述', '文风设定'];
@@ -298,9 +280,6 @@ function Step5StyleAndGenerate({
   includeNegativeSamples: boolean; setIncludeNegativeSamples: (v: boolean) => void;
   onBack: () => void; onComplete: () => void;
 }) {
-  const [fileStates] = useState<FileGenerationState[]>(
-    LORE_FILES.map((f) => ({ key: f.key, label: f.label, status: 'pending' as FileStatus }))
-  );
   const [isRunning, setIsRunning] = useState(false);
   const [allDone, setAllDone] = useState(false);
   const [globalError, setGlobalError] = useState<string | null>(null);

+ 9 - 22
src/app/features/novel-writer/components/WorldStateSidebar.tsx

@@ -16,27 +16,6 @@ interface WorldStateSidebarProps {
   onFileEdit?: (file: WorldStateFile, isLore: boolean) => void;
 }
 
-// 外部打开文件
-const handleOpenExternal = async (projectPath: string, filePath: string) => {
-  try {
-    const response = await fetch('/api/novel/files', {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        action: 'open',
-        projectPath,
-        filePath,
-      }),
-    });
-    const data = await response.json();
-    if (!data.success) {
-      console.error('外部打开失败:', data.error);
-    }
-  } catch (err) {
-    console.error('外部打开失败:', err);
-  }
-};
-
 type HookType = NarrativeHook['type'];
 
 const HOOK_TYPE_OPTIONS: { value: HookType; label: string }[] = [
@@ -613,7 +592,15 @@ export default function WorldStateSidebar({ projectPath, latestChapterIndex, onF
               </svg>
             </button>
             <button
-              onClick={() => handleOpenExternal(projectPath, 'world_state/writing_style.md')}
+              onClick={() => {
+                fetch('/api/novel/files', {
+                  method: 'POST',
+                  headers: { 'Content-Type': 'application/json' },
+                  body: JSON.stringify({ action: 'open', projectPath, filePath: 'world_state/writing_style.md' }),
+                }).then(r => r.json()).then(d => {
+                  if (!d.success) console.error('外部打开失败:', d.error);
+                }).catch(err => console.error('外部打开失败:', err));
+              }}
               className="p-2 text-gray-400 hover:text-green-500 transition-colors rounded"
               title="外部打开"
               aria-label="外部打开文风设定"

+ 0 - 6
src/app/features/novel-writer/components/pipeline/Step4ContentEditor.tsx

@@ -597,12 +597,6 @@ ${antiText}
     }
   }, [apiSettings, projectPath, buildGenerationPrompt, ironRules, styleAnchor, rollingSummaries]);
 
-  useEffect(() => {
-    if (apiSettings && !hasGenerated && !isGenerating) {
-      generateContent();
-    }
-  }, []);
-
   useEffect(() => {
     return () => {
       abortControllerRef.current?.abort();

+ 4 - 3
src/app/lib/novel-utils.ts

@@ -981,9 +981,10 @@ export function parseHooksFromJson(json: string): NarrativeHook[] {
 // ─────────────────────────────────────────────
 // 7. resolveHook
 /**
- * 将指定钩子标记为已解决(JSON格式):
- * - 从 hooks 数组中移除该钩子
- * - 返回更新后的 JSON 内容
+ * 将指定钩子标记为已解决状态(JSON格式):
+ * - 将钩子的 status 设为 resolved
+ * - 记录解决章节和时间戳
+ * - 保留钩子在数组中(不移除)
  *
  * @param hooksJsonContent - narrative_hooks.json 的当前内容
  * @param hookId - 要解决的钩子 ID