402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════
|
|
* 智能小说系统 · Novel Engine Service
|
|
* ═══════════════════════════════════════════════════════════
|
|
*
|
|
* ZY-PROJ-004 · ZY-SVR-006
|
|
* 守护: 铸渊 · ICE-GL-ZY001
|
|
* 版权: 国作登字-2026-A-00037559
|
|
*
|
|
* 功能:
|
|
* - 小说项目管理 (创建/列表/详情/删除)
|
|
* - 章节管理 (CRUD + 排序)
|
|
* - 人物卡管理 (创建/编辑/关系图)
|
|
* - 大纲管理 (层级大纲 + 节点)
|
|
* - AI辅助写作 (续写/改写/情节建议 · SSE流式)
|
|
*
|
|
* ═══════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|
const NOVELS_FILE = path.join(DATA_DIR, 'novels.json');
|
|
|
|
let novelsData = Object.create(null);
|
|
|
|
function sanitizeId(id) {
|
|
if (!id || typeof id !== 'string') return null;
|
|
if (id === '__proto__' || id === 'constructor' || id === 'prototype') return null;
|
|
return id;
|
|
}
|
|
|
|
function init() {
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
load();
|
|
}
|
|
|
|
function load() {
|
|
try {
|
|
if (fs.existsSync(NOVELS_FILE)) {
|
|
const raw = JSON.parse(fs.readFileSync(NOVELS_FILE, 'utf8'));
|
|
novelsData = Object.create(null);
|
|
if (raw.novels) novelsData.novels = raw.novels;
|
|
else novelsData.novels = [];
|
|
} else {
|
|
novelsData.novels = [];
|
|
}
|
|
} catch {
|
|
novelsData.novels = [];
|
|
}
|
|
}
|
|
|
|
function save() {
|
|
try {
|
|
fs.writeFileSync(NOVELS_FILE, JSON.stringify(novelsData, null, 2), 'utf8');
|
|
} catch (err) {
|
|
console.error('[NovelEngine] 保存失败:', err.message);
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 小说项目 CRUD
|
|
// ══════════════════════════════════════════
|
|
|
|
function createNovel({ title, author, genre, synopsis, target_words }) {
|
|
if (!title) throw new Error('title 为必填项');
|
|
|
|
const novelId = `NVL-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
const novel = {
|
|
novel_id: novelId,
|
|
title: title.trim(),
|
|
author: (author || '未知').trim(),
|
|
genre: genre || '玄幻',
|
|
synopsis: (synopsis || '').slice(0, 1000),
|
|
target_words: target_words || 0,
|
|
status: 'drafting',
|
|
chapters: [],
|
|
characters: [],
|
|
outline: [],
|
|
word_count: 0,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
novelsData.novels.push(novel);
|
|
save();
|
|
return novel;
|
|
}
|
|
|
|
function listNovels({ author, status, limit } = {}) {
|
|
let result = [...novelsData.novels];
|
|
if (author) result = result.filter(n => n.author === author);
|
|
if (status) result = result.filter(n => n.status === status);
|
|
result.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
|
if (limit) result = result.slice(0, limit);
|
|
return result;
|
|
}
|
|
|
|
function getNovel(novelId) {
|
|
if (!sanitizeId(novelId)) return null;
|
|
return novelsData.novels.find(n => n.novel_id === novelId) || null;
|
|
}
|
|
|
|
function updateNovel(novelId, updates) {
|
|
if (!sanitizeId(novelId)) throw new Error('非法 novelId');
|
|
const novel = novelsData.novels.find(n => n.novel_id === novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
|
|
const allowed = ['title', 'genre', 'synopsis', 'target_words', 'status'];
|
|
for (const key of allowed) {
|
|
if (updates[key] !== undefined) novel[key] = updates[key];
|
|
}
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return novel;
|
|
}
|
|
|
|
function deleteNovel(novelId) {
|
|
if (!sanitizeId(novelId)) throw new Error('非法 novelId');
|
|
const idx = novelsData.novels.findIndex(n => n.novel_id === novelId);
|
|
if (idx === -1) throw new Error('小说不存在');
|
|
novelsData.novels.splice(idx, 1);
|
|
save();
|
|
return { deleted: true };
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 章节管理
|
|
// ══════════════════════════════════════════
|
|
|
|
function addChapter(novelId, { title, content, order }) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
|
|
const chapterId = `CH-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
|
const chapter = {
|
|
chapter_id: chapterId,
|
|
title: (title || `第${novel.chapters.length + 1}章`).slice(0, 100),
|
|
content: (content || '').slice(0, 50000),
|
|
order: order !== undefined ? order : novel.chapters.length,
|
|
word_count: (content || '').replace(/\s/g, '').length,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
novel.chapters.push(chapter);
|
|
novel.word_count = novel.chapters.reduce((s, c) => s + c.word_count, 0);
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return chapter;
|
|
}
|
|
|
|
function getChapter(novelId, chapterId) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) return null;
|
|
if (!sanitizeId(chapterId)) return null;
|
|
return novel.chapters.find(c => c.chapter_id === chapterId) || null;
|
|
}
|
|
|
|
function updateChapter(novelId, chapterId, updates) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
if (!sanitizeId(chapterId)) throw new Error('非法 chapterId');
|
|
|
|
const chapter = novel.chapters.find(c => c.chapter_id === chapterId);
|
|
if (!chapter) throw new Error('章节不存在');
|
|
|
|
if (updates.title !== undefined) chapter.title = updates.title.slice(0, 100);
|
|
if (updates.content !== undefined) {
|
|
chapter.content = updates.content.slice(0, 50000);
|
|
chapter.word_count = updates.content.replace(/\s/g, '').length;
|
|
}
|
|
if (updates.order !== undefined) chapter.order = updates.order;
|
|
|
|
chapter.updated_at = new Date().toISOString();
|
|
novel.word_count = novel.chapters.reduce((s, c) => s + c.word_count, 0);
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return chapter;
|
|
}
|
|
|
|
function deleteChapter(novelId, chapterId) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
if (!sanitizeId(chapterId)) throw new Error('非法 chapterId');
|
|
|
|
const idx = novel.chapters.findIndex(c => c.chapter_id === chapterId);
|
|
if (idx === -1) throw new Error('章节不存在');
|
|
|
|
novel.chapters.splice(idx, 1);
|
|
novel.word_count = novel.chapters.reduce((s, c) => s + c.word_count, 0);
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return { deleted: true };
|
|
}
|
|
|
|
function listChapters(novelId) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) return [];
|
|
return [...novel.chapters].sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 人物卡管理
|
|
// ══════════════════════════════════════════
|
|
|
|
function addCharacter(novelId, { name, role, description, appearance, personality, relationships }) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
if (!name) throw new Error('name 为必填项');
|
|
|
|
const charId = `CHR-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
|
const character = {
|
|
char_id: charId,
|
|
name: name.trim().slice(0, 50),
|
|
role: role || '配角',
|
|
description: (description || '').slice(0, 1000),
|
|
appearance: (appearance || '').slice(0, 500),
|
|
personality: (personality || '').slice(0, 500),
|
|
relationships: Array.isArray(relationships) ? relationships.slice(0, 20) : [],
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
novel.characters.push(character);
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return character;
|
|
}
|
|
|
|
function updateCharacter(novelId, charId, updates) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
if (!sanitizeId(charId)) throw new Error('非法 charId');
|
|
|
|
const character = novel.characters.find(c => c.char_id === charId);
|
|
if (!character) throw new Error('人物不存在');
|
|
|
|
const allowed = ['name', 'role', 'description', 'appearance', 'personality', 'relationships'];
|
|
for (const key of allowed) {
|
|
if (updates[key] !== undefined) character[key] = updates[key];
|
|
}
|
|
character.updated_at = new Date().toISOString();
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return character;
|
|
}
|
|
|
|
function listCharacters(novelId) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) return [];
|
|
return novel.characters;
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 大纲管理
|
|
// ══════════════════════════════════════════
|
|
|
|
function addOutlineNode(novelId, { title, content, parent_id, order }) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
|
|
const nodeId = `OL-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
|
const node = {
|
|
node_id: nodeId,
|
|
title: (title || '未命名节点').slice(0, 100),
|
|
content: (content || '').slice(0, 2000),
|
|
parent_id: parent_id || null,
|
|
order: order !== undefined ? order : novel.outline.length,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
novel.outline.push(node);
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return node;
|
|
}
|
|
|
|
function updateOutlineNode(novelId, nodeId, updates) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
if (!sanitizeId(nodeId)) throw new Error('非法 nodeId');
|
|
|
|
const node = novel.outline.find(n => n.node_id === nodeId);
|
|
if (!node) throw new Error('节点不存在');
|
|
|
|
if (updates.title !== undefined) node.title = updates.title.slice(0, 100);
|
|
if (updates.content !== undefined) node.content = updates.content.slice(0, 2000);
|
|
if (updates.parent_id !== undefined) node.parent_id = updates.parent_id;
|
|
if (updates.order !== undefined) node.order = updates.order;
|
|
|
|
novel.updated_at = new Date().toISOString();
|
|
save();
|
|
return node;
|
|
}
|
|
|
|
function getOutline(novelId) {
|
|
const novel = getNovel(novelId);
|
|
if (!novel) return [];
|
|
|
|
// 构建层级树
|
|
const nodes = [...novel.outline].sort((a, b) => a.order - b.order);
|
|
const roots = nodes.filter(n => !n.parent_id);
|
|
const tree = roots.map(root => ({
|
|
...root,
|
|
children: nodes.filter(n => n.parent_id === root.node_id)
|
|
}));
|
|
return tree;
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// AI辅助写作 (MVP: 模板生成 · Phase 4: 接入LLM)
|
|
// ══════════════════════════════════════════
|
|
|
|
function aiContinue(novelId, chapterId, options) {
|
|
const prompt = typeof options.prompt === 'string' ? options.prompt : '续写';
|
|
const chapter = getChapter(novelId, chapterId);
|
|
if (!chapter) throw new Error('章节不存在');
|
|
|
|
const lastParagraph = (chapter.content || '').trim().split('\n').slice(-3).join('\n');
|
|
|
|
// MVP: 生成模板续写提示
|
|
return {
|
|
type: 'continue',
|
|
prompt_used: prompt,
|
|
context: lastParagraph.slice(-200),
|
|
generated: `[AI续写预留位 · 接入DeepSeek后自动生成]\n\n基于上文"${lastParagraph.slice(-50)}..."的续写将在此处呈现。\n\n当前为MVP占位内容。`,
|
|
model: 'placeholder',
|
|
note: 'Phase 4 将接入 DeepSeek/Kimi API 实现真实续写'
|
|
};
|
|
}
|
|
|
|
function aiRewrite(novelId, chapterId, options) {
|
|
const text = typeof options.text === 'string' ? options.text : '';
|
|
const style = typeof options.style === 'string' ? options.style : 'default';
|
|
return {
|
|
type: 'rewrite',
|
|
original: text.slice(0, 500),
|
|
style: style,
|
|
generated: `[AI改写预留位 · 接入DeepSeek后自动生成]\n\n原文将按"${style}"风格改写。`,
|
|
model: 'placeholder',
|
|
note: 'Phase 4 将接入 DeepSeek/Kimi API 实现真实改写'
|
|
};
|
|
}
|
|
|
|
function aiSuggest(novelId, options) {
|
|
const type = typeof options.type === 'string' ? options.type : 'plot';
|
|
const novel = getNovel(novelId);
|
|
if (!novel) throw new Error('小说不存在');
|
|
|
|
const charNames = novel.characters.map(c => c.name).join('、') || '暂无人物';
|
|
const chapterCount = novel.chapters.length;
|
|
|
|
return {
|
|
type: type,
|
|
novel_title: novel.title,
|
|
context: `${novel.genre}类型 · ${chapterCount}章 · 人物: ${charNames}`,
|
|
suggestions: [
|
|
`[建议1] 基于当前${chapterCount}章的走势,可以考虑引入新的冲突线...`,
|
|
`[建议2] ${charNames.split('、')[0] || '主角'}的人物弧可以在接下来展开...`,
|
|
`[建议3] 当前节奏适合加入一段过渡章节,调节阅读节奏...`
|
|
],
|
|
model: 'placeholder',
|
|
note: 'Phase 4 将接入 DeepSeek/Kimi API 实现真实情节建议'
|
|
};
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 统计
|
|
// ══════════════════════════════════════════
|
|
|
|
function getStats() {
|
|
const novels = novelsData.novels;
|
|
return {
|
|
total_novels: novels.length,
|
|
total_chapters: novels.reduce((s, n) => s + n.chapters.length, 0),
|
|
total_characters: novels.reduce((s, n) => s + n.characters.length, 0),
|
|
total_words: novels.reduce((s, n) => s + n.word_count, 0),
|
|
by_status: {
|
|
drafting: novels.filter(n => n.status === 'drafting').length,
|
|
writing: novels.filter(n => n.status === 'writing').length,
|
|
completed: novels.filter(n => n.status === 'completed').length,
|
|
paused: novels.filter(n => n.status === 'paused').length
|
|
}
|
|
};
|
|
}
|
|
|
|
init();
|
|
|
|
module.exports = {
|
|
createNovel, listNovels, getNovel, updateNovel, deleteNovel,
|
|
addChapter, getChapter, updateChapter, deleteChapter, listChapters,
|
|
addCharacter, updateCharacter, listCharacters,
|
|
addOutlineNode, updateOutlineNode, getOutline,
|
|
aiContinue, aiRewrite, aiSuggest,
|
|
getStats
|
|
};
|