|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
import { NovelType, DEFAULT_WORLD_STATE_FILES, DEFAULT_LORE_FILES } from '@/app/lib/novel-types';
|
|
import { NovelType, DEFAULT_WORLD_STATE_FILES, DEFAULT_LORE_FILES } from '@/app/lib/novel-types';
|
|
|
import { getDefaultTemplate, getNovelTypeTemplate } from '@/app/lib/novel-utils';
|
|
import { getDefaultTemplate, getNovelTypeTemplate } from '@/app/lib/novel-utils';
|
|
|
import { isPathSafe } from '@/app/lib/novel-server-utils';
|
|
import { isPathSafe } from '@/app/lib/novel-server-utils';
|
|
|
|
|
+import { buildIronRulesPrompt, buildStyleAnchorPrompt } from '@/app/lib/chapter-prompts';
|
|
|
|
|
|
|
|
interface InitRequest {
|
|
interface InitRequest {
|
|
|
projectPath: string;
|
|
projectPath: string;
|
|
@@ -109,6 +110,100 @@ async function atomicWrite(filePath: string, content: string): Promise<void> {
|
|
|
await fs.rename(tmpPath, filePath);
|
|
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) {
|
|
export async function POST(request: NextRequest) {
|
|
|
let body: InitRequest;
|
|
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) {
|
|
if (body.generateIronRules && apiSettings && worldDescription) {
|
|
|
const ironRulesPath = path.join(projectPath, 'iron-rules.md');
|
|
const ironRulesPath = path.join(projectPath, 'iron-rules.md');
|
|
|
try {
|
|
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 {
|
|
} catch {
|
|
|
failedFiles.push('iron-rules.md');
|
|
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) {
|
|
if (body.generateStyleAnchor && apiSettings && body.styleInfo) {
|
|
|
const styleAnchorPath = path.join(projectPath, 'style-anchor.md');
|
|
const styleAnchorPath = path.join(projectPath, 'style-anchor.md');
|
|
|
try {
|
|
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 {
|
|
} catch {
|
|
|
failedFiles.push('style-anchor.md');
|
|
failedFiles.push('style-anchor.md');
|
|
|
}
|
|
}
|