AI 小说生成工作台:分层流水线 + 故事圣经(一期完整实现)

- 后端:Node/Express + node:sqlite,人物/世界观/分卷/细纲/正文 8 阶段生成流水线(SSE 流式 + zod 校验 + 重试)
- 前端:React 18 + Vite + antd 三栏工作台:流水线引导、章节树、编辑器(快切章节/段落改写)、故事圣经面板
- 写作工艺注入:人物声口/内在冲突/秘密,细纲 POV/情绪曲线/功能/张力,正文专业规则
- 自动回写故事圣经(摘要/设定/人物变化/伏笔/时间线),TXT/EPUB 导出,token 成本日志
This commit is contained in:
Administrator
2026-09-13 20:35:47 +08:00
commit 4073edcc20
49 changed files with 9827 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"name": "server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jszip": "^3.10.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^24.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 数据文件固定放在 server/data/ 下,与源码/编译产物位置无关
const dataDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../data')
export const db: DatabaseSync = (() => {
mkdirSync(dataDir, { recursive: true })
const db = new DatabaseSync(path.join(dataDir, 'novel.db'))
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
return db
})()
export const now = () => new Date().toISOString()
+135
View File
@@ -0,0 +1,135 @@
import { db } from './connection.js'
export function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL,
temperature REAL NOT NULL DEFAULT 0.8,
max_tokens INTEGER NOT NULL DEFAULT 12000,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
genre TEXT NOT NULL DEFAULT '',
premise TEXT NOT NULL DEFAULT '',
style_notes TEXT NOT NULL DEFAULT '',
style_sample TEXT NOT NULL DEFAULT '',
target_chapter_words INTEGER NOT NULL DEFAULT 3000,
target_chapter_count INTEGER NOT NULL DEFAULT 30,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '配角',
appearance TEXT NOT NULL DEFAULT '',
personality TEXT NOT NULL DEFAULT '',
background TEXT NOT NULL DEFAULT '',
goals TEXT NOT NULL DEFAULT '',
arc_notes TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS world_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT '其他',
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS volumes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS beats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
volume_id INTEGER REFERENCES volumes(id) ON DELETE SET NULL,
sort INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
involved_characters TEXT NOT NULL DEFAULT '[]',
foreshadow_plan TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
beat_id INTEGER REFERENCES beats(id) ON DELETE SET NULL,
volume_id INTEGER REFERENCES volumes(id) ON DELETE SET NULL,
sort INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'empty',
summary TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS foreshadows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
description TEXT NOT NULL,
planted_chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
paid_chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'planted',
notes TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS timeline_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
story_time TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS generation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
ok INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
`)
// 增量迁移:为旧库补齐新增列(写作专业字段)
const addColumns: [string, string, string][] = [
['projects', 'narrative_pov', "ALTER TABLE projects ADD COLUMN narrative_pov TEXT NOT NULL DEFAULT ''"],
['projects', 'taboos', "ALTER TABLE projects ADD COLUMN taboos TEXT NOT NULL DEFAULT ''"],
['characters', 'speech_pattern', "ALTER TABLE characters ADD COLUMN speech_pattern TEXT NOT NULL DEFAULT ''"],
['characters', 'inner_conflict', "ALTER TABLE characters ADD COLUMN inner_conflict TEXT NOT NULL DEFAULT ''"],
['characters', 'secret', "ALTER TABLE characters ADD COLUMN secret TEXT NOT NULL DEFAULT ''"],
['beats', 'pov', "ALTER TABLE beats ADD COLUMN pov TEXT NOT NULL DEFAULT ''"],
['beats', 'emotional_arc', "ALTER TABLE beats ADD COLUMN emotional_arc TEXT NOT NULL DEFAULT ''"],
['beats', 'purpose', "ALTER TABLE beats ADD COLUMN purpose TEXT NOT NULL DEFAULT ''"],
['beats', 'tension', 'ALTER TABLE beats ADD COLUMN tension INTEGER NOT NULL DEFAULT 0']
]
for (const [table, col, ddl] of addColumns) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[]
if (!cols.some((c) => c.name === col)) db.exec(ddl)
}
}
+39
View File
@@ -0,0 +1,39 @@
import dotenv from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { db, now } from './connection.js'
// 从 src/db 或 dist/db 出发向上 3 层都是项目根目录
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
dotenv.config({ path: path.join(rootDir, '.env') })
export interface LlmConfig {
base_url: string
api_key: string
model: string
temperature: number
max_tokens: number
}
export function seedLlmConfig() {
const row = db.prepare('SELECT id FROM llm_config WHERE id = 1').get()
if (row) return
db.prepare(
'INSERT INTO llm_config (id, base_url, api_key, model, temperature, max_tokens, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?)'
).run(
process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3',
process.env.ARK_API_KEY || '',
process.env.ARK_MODEL || 'doubao-seed-1-6-flash',
0.8,
12000,
now()
)
}
export function getLlmConfig(): LlmConfig {
const row = db.prepare('SELECT base_url, api_key, model, temperature, max_tokens FROM llm_config WHERE id = 1').get() as
| LlmConfig
| undefined
if (!row) throw new Error('LLM 配置缺失,请先启动初始化')
return row
}
+112
View File
@@ -0,0 +1,112 @@
export interface ProjectRow {
id: number
name: string
genre: string
premise: string
style_notes: string
style_sample: string
narrative_pov: string
taboos: string
target_chapter_words: number
target_chapter_count: number
created_at: string
updated_at: string
}
export interface CharacterRow {
id: number
project_id: number
name: string
aliases: string
role: string
appearance: string
personality: string
background: string
goals: string
speech_pattern: string
inner_conflict: string
secret: string
arc_notes: string
sort: number
updated_at: string
}
export interface WorldEntryRow {
id: number
project_id: number
category: string
title: string
content: string
sort: number
updated_at: string
}
export interface VolumeRow {
id: number
project_id: number
title: string
summary: string
sort: number
}
export interface BeatRow {
id: number
project_id: number
volume_id: number | null
sort: number
title: string
events: string
involved_characters: string // JSON array of character ids(数据库原始存储)
foreshadow_plan: string
pov: string
emotional_arc: string
purpose: string
tension: number
}
/** 出站形态:involved_characters 已解析为 id 数组 */
export type BeatParsed = Omit<BeatRow, 'involved_characters'> & { involved_characters: number[] }
export interface ChapterRow {
id: number
project_id: number
beat_id: number | null
volume_id: number | null
sort: number
title: string
content: string
status: string
summary: string
word_count: number
updated_at: string
}
export interface ForeshadowRow {
id: number
project_id: number
description: string
planted_chapter_id: number | null
paid_chapter_id: number | null
status: string
notes: string
updated_at: string
}
export interface TimelineEventRow {
id: number
project_id: number
chapter_id: number | null
story_time: string
title: string
description: string
sort: number
}
export function parseInvolved(json: string): number[] {
try {
const arr = JSON.parse(json)
return Array.isArray(arr) ? arr.map(Number).filter((n) => Number.isInteger(n)) : []
} catch {
return []
}
}
+52
View File
@@ -0,0 +1,52 @@
import express from 'express'
import cors from 'cors'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { migrate } from './db/schema.js'
import { seedLlmConfig } from './db/seed.js'
import { projectsRouter } from './routes/projects.js'
import { assetsRouter } from './routes/assets.js'
import { outlineRouter } from './routes/outline.js'
import { chaptersRouter } from './routes/chapters.js'
import { settingsRouter } from './routes/settings.js'
import { generateRouter } from './routes/generate.js'
import { exportRouter } from './routes/export.js'
migrate()
seedLlmConfig()
const app = express()
app.use(cors())
app.use(express.json({ limit: '10mb' }))
app.use('/api', projectsRouter)
app.use('/api', assetsRouter)
app.use('/api', outlineRouter)
app.use('/api', chaptersRouter)
app.use('/api', settingsRouter)
app.use('/api', generateRouter)
app.use('/api', exportRouter)
app.use('/api', (_req, res) => res.status(404).json({ error: '接口不存在' }))
// 生产模式:托管前端构建产物(SPA 回退)
const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../web/dist')
if (existsSync(webDist)) {
app.use(express.static(webDist))
app.use((req, res, next) => {
if (req.method === 'GET' && !req.path.startsWith('/api')) return res.sendFile(path.join(webDist, 'index.html'))
next()
})
}
// 统一错误处理
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const msg = err instanceof Error ? err.message : String(err)
res.status(500).json({ error: msg })
})
const PORT = Number(process.env.PORT || 5175)
app.listen(PORT, '127.0.0.1', () => {
console.log(`[novel-workbench] API 服务已启动: http://127.0.0.1:${PORT}`)
})
+162
View File
@@ -0,0 +1,162 @@
import type { LlmConfig } from '../db/seed.js'
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface Usage {
prompt_tokens?: number
completion_tokens?: number
}
export interface ChatOpts {
onDelta?: (t: string) => void
signal?: AbortSignal
temperature?: number
maxTokens?: number
}
const joinUrl = (base: string, p: string) => base.replace(/\/+$/, '') + p
function buildBody(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts, extra: boolean) {
const body: Record<string, unknown> = {
model: cfg.model,
messages,
stream,
temperature: opts.temperature ?? cfg.temperature,
max_tokens: opts.maxTokens ?? cfg.max_tokens
}
if (extra) {
// 豆包 seed 系列默认可能开启深度思考;关闭以提速并保持输出干净。
// 不识别该参数的服务商会在 400 中提及,随后自动降级重试。
body.thinking = { type: 'disabled' }
if (stream) body.stream_options = { include_usage: true }
}
return body
}
async function doFetch(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts, extra: boolean) {
return fetch(joinUrl(cfg.base_url, '/chat/completions'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_key}` },
body: JSON.stringify(buildBody(cfg, messages, stream, opts, extra)),
signal: opts.signal
})
}
/** 带降级重试的请求:首次带 thinking disabled / usage 统计,400 时去掉附加参数重发 */
async function fetchResilient(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts) {
let res = await doFetch(cfg, messages, stream, opts, true)
if (!res.ok) {
const errText = await res.text()
if (res.status === 400 && /thinking|stream_options|unknown|unrecognized/i.test(errText)) {
res = await doFetch(cfg, messages, stream, opts, false)
if (!res.ok) throw new Error(`模型接口 HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`)
} else {
throw new Error(`模型接口 HTTP ${res.status}: ${errText.slice(0, 500)}`)
}
}
return res
}
/** 流式对话,逐段回调 onDelta,返回完整内容与用量 */
export async function chatStream(cfg: LlmConfig, messages: ChatMessage[], opts: ChatOpts = {}) {
const res = await fetchResilient(cfg, messages, true, opts)
if (!res.body) throw new Error('模型接口返回空流')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let content = ''
let usage: Usage | undefined
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (payload === '[DONE]') continue
try {
const json = JSON.parse(payload)
const piece: string = json.choices?.[0]?.delta?.content ?? ''
if (piece) {
content += piece
opts.onDelta?.(piece)
}
if (json.usage) usage = json.usage
const errMsg = json.error?.message
if (errMsg) throw new Error('模型流式返回错误: ' + errMsg)
} catch (e) {
if (e instanceof SyntaxError) continue // 忽略无法解析的行
throw e
}
}
}
return { content, usage }
}
/** 非流式对话 */
export async function chatOnce(cfg: LlmConfig, messages: ChatMessage[], opts: ChatOpts = {}) {
const res = await fetchResilient(cfg, messages, false, opts)
const json = await res.json()
const content: string = json.choices?.[0]?.message?.content ?? ''
if (!content) throw new Error('模型返回内容为空: ' + JSON.stringify(json).slice(0, 300))
return { content, usage: json.usage as Usage | undefined }
}
/** 结构化输出:模型 JSON 解析失败时携带错误反馈重试 */
export async function chatStructured<T>(
cfg: LlmConfig,
messages: ChatMessage[],
validate: (text: string) => T,
opts: ChatOpts = {}
): Promise<{ data: T; raw: string; usage?: Usage }> {
let lastErr: unknown
for (let attempt = 0; attempt < 3; attempt++) {
const msgs = [...messages]
if (attempt > 0 && lastErr instanceof Error) {
msgs.push({ role: 'assistant', content: lastErr.message.slice(0, 100) })
msgs.push({ role: 'user', content: `上次输出无法解析(${lastErr.message.slice(0, 200)})。请严格只输出符合要求的 JSON,不要任何其他文字。` })
}
const { content, usage } = await chatStream(cfg, msgs, {
...opts,
temperature: Math.min(opts.temperature ?? cfg.temperature, 0.7),
maxTokens: Math.min(opts.maxTokens ?? cfg.max_tokens, 16000)
})
try {
return { data: validate(content), raw: content, usage }
} catch (e) {
lastErr = e
}
}
throw new Error(`模型未能输出合法 JSON(已重试 3 次): ${lastErr instanceof Error ? lastErr.message : lastErr}`)
}
/** 设置页连通性测试:列模型 + 小对话 */
export async function testConnection(cfg: Pick<LlmConfig, 'base_url' | 'api_key' | 'model'>) {
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_key}` }
let models: string[] | undefined
try {
const r = await fetch(joinUrl(cfg.base_url, '/models'), { headers, signal: AbortSignal.timeout(15000) })
if (r.ok) {
const j = await r.json()
models = (j.data ?? []).map((m: { id: string }) => m.id).slice(0, 50)
}
} catch {
/* models 接口不可用不阻塞测试 */
}
const r = await fetch(joinUrl(cfg.base_url, '/chat/completions'), {
method: 'POST',
headers,
body: JSON.stringify({ model: cfg.model, messages: [{ role: 'user', content: '请只回复两个字:成功' }], max_tokens: 32, temperature: 0.1 }),
signal: AbortSignal.timeout(60000)
})
if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).slice(0, 400)}`)
const j = await r.json()
const reply: string = j.choices?.[0]?.message?.content ?? ''
return { models, reply: reply.trim().slice(0, 100) }
}
+362
View File
@@ -0,0 +1,362 @@
import { z } from 'zod'
import type { ChatMessage } from './client.js'
export interface ProjectLike {
name: string
genre: string
premise: string
style_notes: string
style_sample: string
narrative_pov: string
taboos: string
target_chapter_words: number
target_chapter_count: number
}
export const characterCardSchema = z.object({
name: z.string().min(1),
aliases: z.string().default(''),
role: z.string().default('配角'),
appearance: z.string().default(''),
personality: z.string().default(''),
background: z.string().default(''),
goals: z.string().default(''),
speech_pattern: z.string().default(''),
inner_conflict: z.string().default(''),
secret: z.string().default(''),
arc_notes: z.string().default('')
})
export const characterCardArraySchema = z.array(characterCardSchema).min(1)
export const worldEntrySchema = z.object({
category: z.string().default('其他'),
title: z.string().min(1),
content: z.string().default('')
})
export const worldEntryArraySchema = z.array(worldEntrySchema).min(1)
export const volumeSchema = z.object({
title: z.string().min(1),
summary: z.string().default(''),
chapter_count: z.number().int().min(1)
})
export const volumeArraySchema = z.array(volumeSchema).min(1)
export const beatSchema = z.object({
title: z.string().min(1),
events: z.string().default(''),
involved_characters: z.array(z.string()).default([]),
foreshadow_plan: z.string().default(''),
pov: z.string().default(''),
emotional_arc: z.string().default(''),
purpose: z.string().default(''),
tension: z.number().int().min(0).max(5).default(0)
})
export const beatArraySchema = z.array(beatSchema).min(1)
export const postprocessSchema = z.object({
summary: z.string().default(''),
new_world_facts: z.array(worldEntrySchema).default([]),
character_updates: z.array(z.object({ name: z.string(), update: z.string() })).default([]),
new_foreshadows: z.array(z.string()).default([]),
paid_foreshadow_ids: z.array(z.number().int()).default([]),
timeline_events: z
.array(z.object({ story_time: z.string().default(''), title: z.string(), description: z.string().default('') }))
.default([])
})
const JSON_ONLY = '\n只输出 JSON 本身:不要 markdown 代码块、不要解释、不要任何 JSON 以外的文字。'
function projectBrief(p: ProjectLike): string {
return [
`【作品名】《${p.name}》`,
`【题材】${p.genre || '未指定'}`,
`【故事创意/梗概】\n${p.premise || '(用户未填写,请依据作品名与题材合理发挥)'}`,
p.narrative_pov ? `【叙事视角】${p.narrative_pov}` : '',
p.taboos ? `【禁用内容】${p.taboos}(严禁出现)` : '',
p.style_notes ? `【文风要求】${p.style_notes}` : '',
`【目标规模】约 ${p.target_chapter_count} 章,每章约 ${p.target_chapter_words} 字`
]
.filter(Boolean)
.join('\n')
}
export function charactersMessages(p: ProjectLike): ChatMessage[] {
return [
{
role: 'system',
content: `你是一位资深中文小说编辑,擅长从故事创意中设计立体的人物。
请为这部作品设计 4-8 个相互关联、有戏剧张力的人物卡。要求:
- 人物覆盖核心冲突的各方(主角、盟友、对手、反派等),人物之间要有明确的情感或利益关系
- 性格鲜明独特、有优点也有缺陷,避免脸谱化;配角也要有独立动机
- goals 区分「表层目标」(人物自己想要的)与深层需求(人物真正缺失的),写入 goals 字段
- speech_pattern 写语言习惯:口癖、句式偏好、说话音量与节奏、面对冲突时的说话方式(供正文保持声口一致)
- inner_conflict 写内在冲突:欲望与恐惧、理智与情感的拉扯(好人物都由矛盾驱动)
- secret 写秘密:人物知道但不为人知的事,秘密迟早影响剧情(无则空字符串)
- role 只能取:主角、重要配角、配角、反派${JSON_ONLY}
数组元素格式:
{"name":"姓名","aliases":"昵称或别称,可为空字符串","role":"主角","appearance":"外貌与气质","personality":"性格特点","background":"背景经历","goals":"表层目标与深层需求","speech_pattern":"语言习惯与口癖","inner_conflict":"内在冲突","secret":"秘密","arc_notes":"人物弧光与成长线"}`
},
{ role: 'user', content: projectBrief(p) }
]
}
export function worldMessages(p: ProjectLike): ChatMessage[] {
return [
{
role: 'system',
content: `你是一位资深小说世界观架构师。请为这部作品设计世界观设定,共 6-12 条。
category 只能取:地理、势力、力量体系、物品、规则、历史、其他。
要求:设定具体可用、能直接支撑剧情写作;条目之间自洽;覆盖故事展开所必需的核心设定${JSON_ONLY}
数组元素格式:
{"category":"地理","title":"条目名","content":"具体设定描述"}`
},
{ role: 'user', content: projectBrief(p) }
]
}
export interface CharacterBrief {
name: string
role: string
goals: string
personality: string
}
export interface WorldBrief {
category: string
title: string
content: string
}
export function volumesMessages(p: ProjectLike, characters: CharacterBrief[], world: WorldBrief[]): ChatMessage[] {
const charText = characters.map((c) => `- ${c.name}(${c.role}):${c.goals || c.personality || ''}`).join('\n')
const worldText = world.map((w) => `- [${w.category}] ${w.title}:${w.content.slice(0, 120)}`).join('\n')
return [
{
role: 'system',
content: `你是一位资深小说主笔。请为这部作品设计分卷大纲。
要求:
- 共 1-5 卷(酌情决定),各卷 chapter_count 之和必须恰好等于 ${p.target_chapter_count}
- 整体节奏遵循「开局立意 → 发展升级 → 中段转折 → 高潮决战 → 结局收束」
- 每卷 summary 说明核心冲突、关键事件与该卷结尾的钩子(150-300 字)
- 充分利用下方人物关系与世界观设定
- 只输出一套最终分卷方案:每卷只出现一次,严禁输出备选方案、多个版本或重复条目${JSON_ONLY}
数组元素格式:
{"title":"卷名","summary":"本卷大纲","chapter_count":10}`
},
{
role: 'user',
content: `${projectBrief(p)}\n\n【人物表】\n${charText || '(暂无)'}\n\n【世界观设定】\n${worldText || '(暂无)'}`
}
]
}
export function beatsMessages(
p: ProjectLike,
volume: { title: string; summary: string; chapter_count: number },
characters: CharacterBrief[],
world: WorldBrief[],
prevRecap: string
): ChatMessage[] {
const charText = characters.map((c) => `- ${c.name}(${c.role}):${c.personality};目标:${c.goals}`).join('\n')
const worldText = world.map((w) => `- [${w.category}] ${w.title}:${w.content.slice(0, 150)}`).join('\n')
return [
{
role: 'system',
content: `你是一位资深小说主笔。请为《${volume.title}》卷设计逐章细纲,共 ${volume.chapter_count} 章。
每章要求:
- title 形如「第N章 章名」(N 从给定起始编号连续编号)
- events 用 3-6 句话描述本章关键事件,必须包含冲突或转折,结尾留钩子
- involved_characters 列出本章出场人物姓名(必须从提供的人物表中选取)
- pov 填本章视角人物姓名(从 involved_characters 中选一个,跟随谁的眼睛看故事);用户指定了全书叙事视角时必须遵守
- emotional_arc 填本章主角情绪曲线,形如「警惕→动摇→决意」(好的章节情绪必须有变化)
- purpose 填本章在全局中的功能,只能取:开局、铺垫、升温、转折、高潮、回落、收束、过渡
- tension 填本章张力等级 1-5 整数(全书应呈波浪上升,避免连续多章同分)
- foreshadow_plan 描述本章需要埋下或回收的伏笔,可为空字符串
章节之间承接递进,符合本卷大纲走向;张力和情绪曲线共同构成本卷的节奏
- 只输出一套最终细纲:每章只出现一次,严禁输出备选方案、多个版本或重复条目${JSON_ONLY}
数组元素格式:
{"title":"第N章 章名","events":"...","involved_characters":["人名"],"pov":"视角人物","emotional_arc":"警惕→动摇→决意","purpose":"升温","tension":3,"foreshadow_plan":"..."}`
},
{
role: 'user',
content: [
projectBrief(p),
`【人物表】\n${charText || '(暂无)'}`,
`【世界观设定】\n${worldText || '(暂无)'}`,
prevRecap ? `【前情概要(此前各卷已写内容)】\n${prevRecap}` : '',
`【本卷大纲】${volume.title}:${volume.summary}`
]
.filter(Boolean)
.join('\n\n')
}
]
}
export interface ProseContext {
project: ProjectLike
chapterNo: number
chapterTitle: string
volumeTitle?: string
beat?: {
events: string
foreshadow_plan: string
involved: string[]
pov: string
emotional_arc: string
purpose: string
tension: number
}
characterCards: string[]
worldEntries: string[]
prevSummaries: string[]
prevTail?: string
openForeshadows: string[]
}
export function proseMessages(ctx: ProseContext): ChatMessage[] {
const styleLine = ctx.project.style_notes ? `\n9. 文风要求:${ctx.project.style_notes}` : ''
const sampleLine = ctx.project.style_sample
? `\n【文风示例】(模仿其笔法与节奏,严禁抄写其内容):\n${ctx.project.style_sample.slice(0, 800)}`
: ''
return [
{
role: 'system',
content: `你是一位中文小说写作大师,正在按细纲为长篇小说撰写正文。写作要求:
1. 直接输出正文:第一段就是小说内容,不要输出章节标题、不要任何解释总结、不要出现"本章完"之类字样
2. 严格依据【本章细纲】推进剧情,细纲中的关键事件必须全部写到,不得擅自偏离
3. 人物言行必须符合人物卡的性格与语言习惯(speech_pattern);不同人物开口要有可区分的声口
4. 视角纪律:只写视角人物的所见所闻与内心,其他人物的内心只能通过表情、动作、语气等外部细节侧写,严禁视角漂移
5. 展示而非陈述(show, don't tell):情绪用动作、生理反应、环境互动来呈现,少用"他很愤怒"式直陈
6. 对话要有潜台词:人物不会直说真实意图,让立场与情绪藏在措辞、回避和答非所问里
7. 以场景与对话推动叙事,多用感官细节与人物动作,避免大段静态说明与信息倾泻(背景信息拆碎融进场景)
8. 篇幅约 ${ctx.project.target_chapter_words} 字,自然分段,段落之间空一行;结尾按细纲留钩子${styleLine}${sampleLine}`
},
{
role: 'user',
content: [
`【作品】《${ctx.project.name}》 题材:${ctx.project.genre || '未指定'}`,
ctx.project.narrative_pov ? `【全书叙事视角】${ctx.project.narrative_pov}` : '',
ctx.project.taboos ? `【禁用内容(严禁出现)】${ctx.project.taboos}` : '',
ctx.project.premise ? `【故事创意】${ctx.project.premise.slice(0, 600)}` : '',
ctx.worldEntries.length ? `【相关世界观设定】\n${ctx.worldEntries.join('\n')}` : '',
ctx.characterCards.length ? `【人物卡】\n${ctx.characterCards.join('\n\n')}` : '',
ctx.prevSummaries.length ? `【前情提要】\n${ctx.prevSummaries.join('\n')}` : '',
ctx.openForeshadows.length ? `【尚未回收的伏笔】\n${ctx.openForeshadows.map((f, i) => `${i + 1}. ${f}`).join('\n')}` : '',
ctx.prevTail ? `【上一章结尾】\n…${ctx.prevTail}` : '',
`【本章细纲】${ctx.volumeTitle ? `所属卷:${ctx.volumeTitle};` : ''}第${ctx.chapterNo}章《${ctx.chapterTitle}》`,
ctx.beat?.events ? `本章事件:${ctx.beat.events}` : '(无细纲,请依据前情自然推进)',
ctx.beat?.involved.length ? `本章涉及人物:${ctx.beat.involved.join('、')}` : '',
ctx.beat?.pov ? `本章视角人物:${ctx.beat.pov}` : '',
ctx.beat?.emotional_arc ? `本章情绪曲线(起点→终点,必须有变化):${ctx.beat.emotional_arc}` : '',
ctx.beat?.purpose ? `本章功能定位:${ctx.beat.purpose}` : '',
ctx.beat?.tension ? `本章张力等级:${ctx.beat.tension}/5(按等级控制冲突强度与节奏快慢)` : '',
ctx.beat?.foreshadow_plan ? `伏笔安排:${ctx.beat.foreshadow_plan}` : '',
'现在请撰写本章正文。'
]
.filter(Boolean)
.join('\n\n')
}
]
}
export interface RewriteParams {
mode: 'rewrite' | 'expand' | 'shrink' | 'custom'
instruction?: string
before: string
selected: string
after: string
}
export function rewriteMessages(p: RewriteParams): ChatMessage[] {
const modeText = {
rewrite: '重写该段:保持情节信息不变,提升文笔、画面感与人物声口',
expand: '扩写该段:补充动作、细节与心理描写,篇幅约为原来的 1.5-2 倍',
shrink: '压缩该段:保留关键信息,篇幅约为原来的一半',
custom: `按用户指令改写该段:${p.instruction || '(未给出指令)'}`
}[p.mode]
return [
{
role: 'system',
content: `你是中文小说润色师。任务:${modeText}。
要求:与前后文自然衔接;保持叙事视角与人称一致;只输出改写后的段落文本本身,不要解释、不要用引号包裹。`
},
{
role: 'user',
content: `${p.before ? `【前文】\n…${p.before}\n\n` : ''}【选中段落】\n${p.selected}\n\n${p.after ? `【后文】\n${p.after}…\n\n` : ''}请输出改写后的段落。`
}
]
}
export interface CharacterBackfillInput {
name: string
role: string
aliases: string
personality: string
background: string
goals: string
}
export const characterBackfillSchema = z.object({
name: z.string().min(1),
speech_pattern: z.string().default(''),
inner_conflict: z.string().default(''),
secret: z.string().default('')
})
export const characterBackfillArraySchema = z.array(characterBackfillSchema).min(1)
/** 为已有人物卡补写语言习惯 / 内在冲突 / 秘密三项专业字段 */
export function characterBackfillMessages(chars: CharacterBackfillInput[], p: ProjectLike): ChatMessage[] {
const list = chars
.map(
(c) =>
`- ${c.name}(${c.role}${c.aliases ? `,别称${c.aliases}` : ''}):性格 ${c.personality.slice(0, 120) || '(未填)'};背景 ${c.background.slice(0, 120) || '(未填)'};目标 ${c.goals.slice(0, 100) || '(未填)'}`
)
.join('\n')
return [
{
role: 'system',
content: `你是资深小说编辑。请依据给出的人物卡要点,为每个人物补写三项专业字段:
- speech_pattern 语言习惯与口癖:口头禅、句式偏好、说话节奏与音量、面对冲突时的说话方式;不同人物的声口必须彼此可区分
- inner_conflict 内在冲突:欲望与恐惧、理智与情感的拉扯,一句话点到要害
- secret 秘密:该人物知道但不为人知、未来可能影响剧情的事;没有秘密可填空字符串,但至少一半人物要有实质秘密
只输出 JSON 数组:每个人物一条,name 必须与输入完全一致,不得增删或改写人物${JSON_ONLY}
数组元素格式:
{"name":"姓名","speech_pattern":"...","inner_conflict":"...","secret":"..."}`
},
{ role: 'user', content: `【作品】《${p.name}》(${p.genre || '未指定题材'})\n【人物】\n${list}` }
]
}
export interface PostprocessInput {
chapterTitle: string
chapterNo: number
content: string
foreshadows: { id: number; description: string }[]
characterNames: string[]
}
export function postprocessMessages(input: PostprocessInput): ChatMessage[] {
return [
{
role: 'system',
content: `你是小说连载维护助手。阅读给出的章节正文,输出结构化 JSON 用于维护"故事圣经"。
- summary:本章剧情摘要,120-200 字,第三人称
- new_world_facts:本章新出现且值得长期记住的设定(新的地点、规则、物品、组织等),没有则空数组
- character_updates:本章中人物的状态/关系/能力/立场发生的变化,name 必须是已有人物名
- new_foreshadows:本章埋下的新伏笔(一句话一条)
- paid_foreshadow_ids:从提供的未回收伏笔列表中,选出本章已经回收/兑现的伏笔 id
- timeline_events:本章发生的关键事件(按故事内时间排序),story_time 填故事内时间(可为空字符串)${JSON_ONLY}
格式:
{"summary":"...","new_world_facts":[{"category":"地理|势力|力量体系|物品|规则|历史|其他","title":"...","content":"..."}],"character_updates":[{"name":"...","update":"..."}],"new_foreshadows":["..."],"paid_foreshadow_ids":[1,2],"timeline_events":[{"story_time":"...","title":"...","description":"..."}]}`
},
{
role: 'user',
content: [
`【章节】第${input.chapterNo}章《${input.chapterTitle}》`,
`【已有人物】${input.characterNames.join('、') || '(无)'}`,
`【未回收伏笔】${input.foreshadows.length ? JSON.stringify(input.foreshadows) : '(无)'}`,
`【正文】\n${input.content}`
].join('\n')
}
]
}
+108
View File
@@ -0,0 +1,108 @@
import { db } from '../db/connection.js'
import type { BeatParsed, ChapterRow, CharacterRow, ForeshadowRow, ProjectRow, TimelineEventRow, VolumeRow, WorldEntryRow } from '../db/types.js'
import { parseInvolved } from '../db/types.js'
import type { ProseContext } from '../llm/prompts.js'
import type { ProjectLike } from '../llm/prompts.js'
export interface Bundle {
project: ProjectRow
characters: CharacterRow[]
world: WorldEntryRow[]
volumes: VolumeRow[]
beats: BeatParsed[]
chapters: ChapterRow[]
foreshadows: ForeshadowRow[]
timeline: TimelineEventRow[]
}
export function loadBundle(projectId: number): Bundle | null {
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId) as ProjectRow | undefined
if (!project) return null
// involved_characters 以 JSON 字符串存储,出站统一解析为数组
const beats = (db.prepare('SELECT * FROM beats WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as BeatParsed[]).map(
(b) => ({ ...b, involved_characters: parseInvolved(b.involved_characters as unknown as string) })
)
return {
project,
characters: db.prepare('SELECT * FROM characters WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as CharacterRow[],
world: db.prepare('SELECT * FROM world_entries WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as WorldEntryRow[],
volumes: db.prepare('SELECT * FROM volumes WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as VolumeRow[],
beats,
chapters: db.prepare('SELECT * FROM chapters WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as ChapterRow[],
foreshadows: db.prepare('SELECT * FROM foreshadows WHERE project_id = ? ORDER BY id').all(projectId) as unknown as ForeshadowRow[],
timeline: db.prepare('SELECT * FROM timeline_events WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as TimelineEventRow[]
}
}
export function toProjectLike(p: ProjectRow): ProjectLike {
return {
name: p.name,
genre: p.genre,
premise: p.premise,
style_notes: p.style_notes,
style_sample: p.style_sample,
narrative_pov: p.narrative_pov,
taboos: p.taboos,
target_chapter_words: p.target_chapter_words,
target_chapter_count: p.target_chapter_count
}
}
/** 为指定章节组装正文生成上下文 */
export function buildProseContext(bundle: Bundle, chapter: ChapterRow): ProseContext {
const chapterNo = bundle.chapters.findIndex((c) => c.id === chapter.id) + 1
const volume = bundle.volumes.find((v) => v.id === chapter.volume_id)
const beat = bundle.beats.find((b) => b.id === chapter.beat_id)
// 人物卡:细纲指定了涉及人物则取这些,否则取全部(上限 12 张)
const involvedIds = beat?.involved_characters ?? []
let chars = involvedIds.length
? involvedIds.map((id) => bundle.characters.find((c) => c.id === id)).filter((c): c is CharacterRow => !!c)
: bundle.characters.slice(0, 12)
if (chars.length === 0) chars = bundle.characters.slice(0, 12)
const characterCards = chars.map((c) =>
`【${c.name}】${c.aliases ? `(别称:${c.aliases})` : ''}(${c.role})\n外貌:${c.appearance}\n性格:${c.personality}\n背景:${c.background}\n目标:${c.goals}${c.speech_pattern ? `\n语言习惯:${c.speech_pattern}` : ''}${c.inner_conflict ? `\n内在冲突:${c.inner_conflict}` : ''}${c.secret ? `\n秘密(绝不可被其他人物直接知晓,除非剧情已揭示):${c.secret}` : ''}${c.arc_notes ? `\n近况与弧光:${c.arc_notes.slice(-400)}` : ''}`
)
// 世界观:数量少则全量注入,多则注入与细纲文本相关的条目
const beatText = beat ? `${beat.title} ${beat.events} ${beat.foreshadow_plan}` : chapter.title
const worldEntries = (
bundle.world.length <= 15
? bundle.world
: bundle.world.filter((w) => beatText.includes(w.title) || w.category === '规则' || w.category === '力量体系')
)
.slice(0, 15)
.map((w) => `[${w.category}] ${w.title}:${w.content}`)
// 前情:所有已写章节的摘要 + 上一章结尾 500 字
const idx = bundle.chapters.findIndex((c) => c.id === chapter.id)
const prevChapters = bundle.chapters.slice(0, Math.max(idx, 0)).filter((c) => c.content.trim().length > 0)
const prevSummaries = prevChapters.map((c, i) => `第${i + 1}章《${c.title}》:${c.summary || c.content.slice(0, 120) + '…'}`)
const prev = idx > 0 ? bundle.chapters[idx - 1] : undefined
const prevTail = prev && prev.content.trim() ? prev.content.replace(/\s+/g, ' ').slice(-500) : undefined
const openForeshadows = bundle.foreshadows.filter((f) => f.status === 'planted').map((f) => f.description)
return {
project: toProjectLike(bundle.project),
chapterNo: chapterNo || 1,
chapterTitle: chapter.title,
volumeTitle: volume?.title,
beat: beat
? {
events: beat.events,
foreshadow_plan: beat.foreshadow_plan,
involved: beat.involved_characters.map((id) => bundle.characters.find((c) => c.id === id)?.name ?? '').filter(Boolean),
pov: beat.pov,
emotional_arc: beat.emotional_arc,
purpose: beat.purpose,
tension: beat.tension
}
: undefined,
characterCards,
worldEntries,
prevSummaries,
prevTail,
openForeshadows
}
}
+117
View File
@@ -0,0 +1,117 @@
import { db, now } from '../db/connection.js'
import type { ChapterRow, CharacterRow, ForeshadowRow } from '../db/types.js'
import { getLlmConfig } from '../db/seed.js'
import { chatStructured } from '../llm/client.js'
import { postprocessMessages, postprocessSchema } from '../llm/prompts.js'
import { parseStructured } from '../util/json.js'
import { loadBundle } from './context.js'
import { countWords } from '../util/words.js'
export interface PostprocessResult {
summary: string
new_world_facts: number
character_updates: number
new_foreshadows: number
paid_foreshadows: number
timeline_events: number
}
/** 章节正文生成完成后:提取摘要/新设定/人物变化/伏笔/时间线并写回故事圣经 */
export async function runPostprocess(projectId: number, chapterId: number): Promise<PostprocessResult> {
const bundle = loadBundle(projectId)
if (!bundle) throw new Error('项目不存在')
const chapter = bundle.chapters.find((c) => c.id === chapterId) as ChapterRow | undefined
if (!chapter) throw new Error('章节不存在')
const content = chapter.content.trim()
if (!content) throw new Error('章节内容为空,无法回写')
const chapterNo = bundle.chapters.findIndex((c) => c.id === chapterId) + 1
const open = bundle.foreshadows.filter((f: ForeshadowRow) => f.status === 'planted')
const cfg = getLlmConfig()
const { data, usage } = await chatStructured(
cfg,
postprocessMessages({
chapterTitle: chapter.title,
chapterNo: chapterNo || 1,
content,
foreshadows: open.map((f) => ({ id: f.id, description: f.description.slice(0, 80) })),
characterNames: bundle.characters.map((c: CharacterRow) => c.name)
}),
(text) => parseStructured(text, postprocessSchema),
{ temperature: 0.3 }
)
db.prepare(
'INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)'
).run(projectId, 'postprocess', cfg.model, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, now())
db.exec('BEGIN')
try {
// 1. 摘要与字数
db.prepare('UPDATE chapters SET summary = ?, word_count = ?, updated_at = ? WHERE id = ?').run(
data.summary,
countWords(content),
now(),
chapterId
)
// 2. 新世界观条目
for (const w of data.new_world_facts) {
db.prepare('INSERT INTO world_entries (project_id, category, title, content, sort, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
w.category || '其他',
w.title,
w.content,
999,
now()
)
}
// 3. 人物近况追加到 arc_notes
for (const u of data.character_updates) {
const char = bundle.characters.find((c) => c.name === u.name || (c.aliases && c.aliases.includes(u.name)))
if (!char || !u.update) continue
const appended = `${char.arc_notes ? char.arc_notes + '\n' : ''}[第${chapterNo}章] ${u.update}`
db.prepare('UPDATE characters SET arc_notes = ?, updated_at = ? WHERE id = ?').run(appended.slice(-2000), now(), char.id)
}
// 4. 新伏笔
for (const desc of data.new_foreshadows) {
if (!desc.trim()) continue
db.prepare(
'INSERT INTO foreshadows (project_id, description, planted_chapter_id, status, notes, updated_at) VALUES (?,?,?,?,?,?)'
).run(projectId, desc.trim(), chapterId, 'planted', '', now())
}
// 5. 回收伏笔
for (const id of data.paid_foreshadow_ids) {
const f = open.find((x) => x.id === id)
if (!f) continue
db.prepare("UPDATE foreshadows SET status = 'paid', paid_chapter_id = ?, updated_at = ? WHERE id = ?").run(chapterId, now(), id)
}
// 6. 时间线
let sortBase = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM timeline_events WHERE project_id = ?').get(projectId) as { m: number }).m
for (const t of data.timeline_events) {
if (!t.title.trim()) continue
sortBase += 1
db.prepare('INSERT INTO timeline_events (project_id, chapter_id, story_time, title, description, sort) VALUES (?,?,?,?,?,?)').run(
projectId,
chapterId,
t.story_time,
t.title,
t.description,
sortBase
)
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
return {
summary: data.summary,
new_world_facts: data.new_world_facts.length,
character_updates: data.character_updates.length,
new_foreshadows: data.new_foreshadows.length,
paid_foreshadows: data.paid_foreshadow_ids.length,
timeline_events: data.timeline_events.length
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Router } from 'express'
import { z } from 'zod'
import type { SQLInputValue } from 'node:sqlite'
import { db, now } from '../db/connection.js'
/** 人物 / 世界观 / 伏笔 / 时间线 的 CRUD */
interface TableSpec {
path: string
table: string
orderBy: string
schema: z.ZodObject<z.ZodRawShape>
touchUpdatedAt: boolean
}
function crudRouter(spec: TableSpec): Router {
const r = Router()
const { table, schema } = spec
r.get(`/projects/:projectId/${spec.path}`, (req, res) => {
res.json(db.prepare(`SELECT * FROM ${table} WHERE project_id = ? ORDER BY ${spec.orderBy}`).all(Number(req.params.projectId)))
})
r.post(`/projects/:projectId/${spec.path}`, (req, res) => {
const projectId = Number(req.params.projectId)
if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId)) return res.status(404).json({ error: '项目不存在' })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data as Record<string, unknown>
// zod 已为所有字段补默认值,全部入库
const cols = ['project_id', ...Object.keys(schema.shape)]
const values: SQLInputValue[] = [projectId, ...cols.slice(1).map((c) => d[c] as SQLInputValue)]
if (spec.touchUpdatedAt) {
cols.push('updated_at')
values.push(now())
}
const info = db
.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`)
.run(...values)
res.json(db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(info.lastInsertRowid))
})
// 注意:按前端调用的 path(如 world / timeline)注册,而不是数据库表名(world_entries / timeline_events)
r.patch(`/${spec.path}/:id`, (req, res) => {
const parsed = schema.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined) as [string, SQLInputValue][]
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
if (spec.touchUpdatedAt) entries.push(['updated_at', now()])
const setClause = entries.map(([c]) => `${c} = ?`).join(', ')
db.prepare(`UPDATE ${table} SET ${setClause} WHERE id = ?`).run(...entries.map(([, v]) => v), Number(req.params.id))
res.json(db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(Number(req.params.id)))
})
r.delete(`/${spec.path}/:id`, (req, res) => {
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(Number(req.params.id))
res.json({ ok: true })
})
return r
}
export const assetsRouter = Router()
assetsRouter.use(
crudRouter({
path: 'characters',
table: 'characters',
orderBy: 'sort, id',
touchUpdatedAt: true,
schema: z.object({
name: z.string().min(1).max(60),
aliases: z.string().max(500).default(''),
role: z.string().max(30).default('配角'),
appearance: z.string().max(6000).default(''),
personality: z.string().max(6000).default(''),
background: z.string().max(12000).default(''),
goals: z.string().max(6000).default(''),
speech_pattern: z.string().max(6000).default(''),
inner_conflict: z.string().max(6000).default(''),
secret: z.string().max(6000).default(''),
arc_notes: z.string().max(12000).default(''),
sort: z.number().int().default(0)
})
})
)
assetsRouter.use(
crudRouter({
path: 'world',
table: 'world_entries',
orderBy: 'sort, id',
touchUpdatedAt: true,
schema: z.object({
category: z.string().max(30).default('其他'),
title: z.string().min(1).max(100),
content: z.string().max(12000).default(''),
sort: z.number().int().default(0)
})
})
)
assetsRouter.use(
crudRouter({
path: 'foreshadows',
table: 'foreshadows',
orderBy: 'id',
touchUpdatedAt: true,
schema: z.object({
description: z.string().min(1).max(4000),
planted_chapter_id: z.number().int().nullable().default(null),
paid_chapter_id: z.number().int().nullable().default(null),
status: z.enum(['planted', 'paid']).default('planted'),
notes: z.string().max(8000).default('')
})
})
)
assetsRouter.use(
crudRouter({
path: 'timeline',
table: 'timeline_events',
orderBy: 'sort, id',
touchUpdatedAt: false,
schema: z.object({
chapter_id: z.number().int().nullable().default(null),
story_time: z.string().max(100).default(''),
title: z.string().min(1).max(200),
description: z.string().max(8000).default(''),
sort: z.number().int().default(0)
})
})
)
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { countWords } from '../util/words.js'
import { runPostprocess } from '../pipeline/postprocess.js'
export const chaptersRouter = Router()
const chapterInput = z.object({
beat_id: z.number().int().nullable().default(null),
volume_id: z.number().int().nullable().default(null),
title: z.string().min(1).max(120),
sort: z.number().int().default(0)
})
chaptersRouter.post('/projects/:projectId/chapters', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = chapterInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const info = db
.prepare('INSERT INTO chapters (project_id, beat_id, volume_id, sort, title, updated_at) VALUES (?,?,?,?,?,?)')
.run(projectId, d.beat_id, d.volume_id, d.sort, d.title, now())
res.json(db.prepare('SELECT * FROM chapters WHERE id = ?').get(info.lastInsertRowid))
})
chaptersRouter.get('/chapters/:id', (req, res) => {
const row = db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id))
if (!row) return res.status(404).json({ error: '章节不存在' })
res.json(row)
})
chaptersRouter.patch('/chapters/:id', (req, res) => {
const parsed = z
.object({
title: z.string().min(1).max(120).optional(),
content: z.string().max(500000).optional(),
status: z.enum(['empty', 'generated', 'edited']).optional(),
sort: z.number().int().optional(),
volume_id: z.number().int().nullable().optional(),
summary: z.string().max(4000).optional()
})
.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const cur = db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id)) as
| { content: string; word_count: number }
| undefined
if (!cur) return res.status(404).json({ error: '章节不存在' })
const wordCount = d.content !== undefined ? countWords(d.content) : cur.word_count
db.prepare('UPDATE chapters SET title = COALESCE(?, title), content = COALESCE(?, content), status = COALESCE(?, status), sort = COALESCE(?, sort), volume_id = COALESCE(?, volume_id), summary = COALESCE(?, summary), word_count = ?, updated_at = ? WHERE id = ?').run(
d.title ?? null,
d.content ?? null,
d.status ?? null,
d.sort ?? null,
d.volume_id ?? null,
d.summary ?? null,
wordCount,
now(),
Number(req.params.id)
)
res.json(db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id)))
})
chaptersRouter.delete('/chapters/:id', (req, res) => {
db.prepare('DELETE FROM chapters WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
// 正文定稿后触发:AI 提取摘要/设定/人物变化/伏笔/时间线并写回故事圣经
chaptersRouter.post('/chapters/:id/postprocess', async (req, res) => {
const id = Number(req.params.id)
const row = db.prepare('SELECT project_id FROM chapters WHERE id = ?').get(id) as { project_id: number } | undefined
if (!row) return res.status(404).json({ error: '章节不存在' })
try {
const result = await runPostprocess(row.project_id, id)
res.json(result)
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) })
}
})
+52
View File
@@ -0,0 +1,52 @@
import { Router } from 'express'
import { loadBundle } from '../pipeline/context.js'
import { buildEpub } from '../util/epub.js'
import type { EpubChapter } from '../util/epub.js'
export const exportRouter = Router()
function collect(projectId: number) {
const bundle = loadBundle(projectId)
if (!bundle) return null
const written = bundle.chapters.filter((c) => c.content.trim().length > 0)
return { bundle, written }
}
const filenameHeader = (name: string, ext: string) =>
`attachment; filename*=UTF-8''${encodeURIComponent(`${name}.${ext}`)}`
exportRouter.get('/export/:projectId/txt', (req, res) => {
const data = collect(Number(req.params.projectId))
if (!data) return res.status(404).json({ error: '项目不存在' })
const { bundle, written } = data
const parts: string[] = [`《${bundle.project.name}》`, bundle.project.genre ? `题材:${bundle.project.genre}` : '', '']
for (const c of written) {
const vol = bundle.volumes.find((v) => v.id === c.volume_id)
parts.push(`\n\n══════ 第${bundle.chapters.findIndex((x) => x.id === c.id) + 1}章 ${c.title} ══════\n`)
if (vol) parts.push(`(${vol.title})\n\n`)
parts.push(c.content.trim())
}
res.set('Content-Type', 'text/plain; charset=utf-8')
res.set('Content-Disposition', filenameHeader(bundle.project.name, 'txt'))
res.send('\ufeff' + parts.join('\n'))
})
exportRouter.get('/export/:projectId/epub', async (req, res) => {
const data = collect(Number(req.params.projectId))
if (!data) return res.status(404).json({ error: '项目不存在' })
const { bundle, written } = data
if (written.length === 0) return res.status(400).json({ error: '暂无已写章节,无法导出' })
const chapters: EpubChapter[] = written.map((c) => ({
volumeTitle: bundle.volumes.find((v) => v.id === c.volume_id)?.title,
title: c.title,
paragraphs: c.content.split(/\n+/).map((s) => s.trim()).filter(Boolean)
}))
try {
const buf = await buildEpub(bundle.project.name, chapters)
res.set('Content-Type', 'application/epub+zip')
res.set('Content-Disposition', filenameHeader(bundle.project.name, 'epub'))
res.send(buf)
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) })
}
})
+450
View File
@@ -0,0 +1,450 @@
import { Router } from 'express'
import type { Request, Response } from 'express'
import type { SQLInputValue } from 'node:sqlite'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { getLlmConfig } from '../db/seed.js'
import type { BeatParsed, BeatRow, ChapterRow, VolumeRow } from '../db/types.js'
import { parseInvolved } from '../db/types.js'
import { chatStream, chatStructured, type ChatMessage } from '../llm/client.js'
import {
beatArraySchema,
beatsMessages,
characterBackfillArraySchema,
characterBackfillMessages,
characterCardArraySchema,
charactersMessages,
proseMessages,
rewriteMessages,
volumeArraySchema,
volumesMessages,
worldEntryArraySchema,
worldMessages
} from '../llm/prompts.js'
import { buildProseContext, loadBundle, toProjectLike } from '../pipeline/context.js'
import { parseStructured } from '../util/json.js'
import { countWords } from '../util/words.js'
import { sseInit, sseSend } from '../util/sse.js'
import { mapNamesToIds } from './outline.js'
export const generateRouter = Router()
/** 通用流式执行:把模型增量透传为 SSE,结束时回调并记录日志 */
async function streamStage(req: Request, res: Response, stage: string, projectId: number, messages: Parameters<typeof chatStream>[1]) {
sseInit(res)
const cfg = getLlmConfig()
const abort = new AbortController()
req.on('close', () => abort.abort())
let usage: { prompt_tokens?: number; completion_tokens?: number } | undefined
try {
const result = await chatStream(cfg, messages, {
signal: abort.signal,
onDelta: (t) => sseSend(res, 'delta', { text: t })
})
usage = result.usage
sseSend(res, 'done', { content: result.content, usage: result.usage })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
projectId,
stage,
cfg.model,
result.usage?.prompt_tokens ?? 0,
result.usage?.completion_tokens ?? 0,
now()
)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (!abort.signal.aborted) {
sseSend(res, 'error', { message: msg })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(projectId, stage, cfg.model, now())
}
} finally {
res.end()
}
}
/** 结构化生成阶段:流式输出原始文本 + done 事件携带解析后的 JSON */
async function structuredStage<T>(
req: Request,
res: Response,
stage: string,
projectId: number,
messages: Parameters<typeof chatStructured>[1],
validate: (text: string) => T
) {
sseInit(res)
const cfg = getLlmConfig()
const abort = new AbortController()
req.on('close', () => abort.abort())
try {
const { data, usage } = await chatStructured(
cfg,
messages,
validate,
{ signal: abort.signal, onDelta: (t) => sseSend(res, 'delta', { text: t }) }
)
sseSend(res, 'done', { data, usage })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
projectId,
stage,
cfg.model,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
now()
)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (!abort.signal.aborted) {
sseSend(res, 'error', { message: msg })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(projectId, stage, cfg.model, now())
}
} finally {
res.end()
}
}
const pidBody = z.object({ projectId: z.number().int(), requirement: z.string().max(3000).optional() })
/** 把用户对本步骤的额外要求追加到最后一条 user 消息(人工约束优先于默认策略) */
function withRequirement(messages: ChatMessage[], requirement?: string): ChatMessage[] {
const text = requirement?.trim()
if (!text) return messages
const msgs = [...messages]
const last = msgs[msgs.length - 1]
msgs[msgs.length - 1] = { ...last, content: `${last.content}\n\n【用户对本次生成的额外要求(必须遵守,与默认要求冲突时以用户要求为准)】\n${text}` }
return msgs
}
/** 提取标题中的序号(卷一/第1卷/第一部/第12章),模型输出多版本方案时据此去重 */
function titleOrdinal(title: string, kind: 'vol' | 'beat'): string | null {
const t = title.replace(/\s+/g, '')
if (kind === 'beat') return /^第([0-90-9一二三四五六七八九十百]+)[章回]/.exec(t)?.[1] ?? null
// 「第1卷 / 1卷 / 第一部」数字在前,「卷一 / 部二」数字在后,两种写法都支持
const num = '[0-90-9一二三四五六七八九十百]+'
const m = new RegExp(`^(?:第)?(${num})[卷部篇]`).exec(t) ?? new RegExp(`^[卷部篇](${num})`).exec(t)
return m?.[1] ?? null
}
function normalizeTitleKey(title: string): string {
return title.replace(/[\s::||·、,,。.\-—~~()()【】[\]]+/g, '')
}
/** 模型偶尔会输出多套方案(同一序号出现多次),只保留每个序号的第一条 */
function dedupeByTitle<T>(items: T[], titleOf: (t: T) => string, kind: 'vol' | 'beat'): T[] {
const seenOrd = new Set<string>()
const seenText = new Set<string>()
const out: T[] = []
for (const it of items) {
const ord = titleOrdinal(titleOf(it), kind)
if (ord) {
if (seenOrd.has(ord)) continue
seenOrd.add(ord)
} else {
const key = normalizeTitleKey(titleOf(it))
if (seenText.has(key)) continue
seenText.add(key)
}
out.push(it)
}
return out
}
// ---------- 人物卡 ----------
generateRouter.post('/generate/characters', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'characters', bundle.project.id, withRequirement(charactersMessages(toProjectLike(bundle.project)), parsed.data.requirement), (t) =>
parseStructured(t, characterCardArraySchema)
)
})
// ---------- 世界观 ----------
generateRouter.post('/generate/world', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'world', bundle.project.id, withRequirement(worldMessages(toProjectLike(bundle.project)), parsed.data.requirement), (t) =>
parseStructured(t, worldEntryArraySchema)
)
})
// ---------- 分卷大纲 ----------
generateRouter.post('/generate/volumes', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'volumes', bundle.project.id, withRequirement(volumesMessages(toProjectLike(bundle.project), bundle.characters, bundle.world), parsed.data.requirement), (t) =>
dedupeByTitle(parseStructured(t, volumeArraySchema), (v) => v.title, 'vol')
)
})
// ---------- 分章细纲(按卷) ----------
generateRouter.post('/generate/beats', (req, res) => {
const parsed = z.object({ projectId: z.number().int(), volumeId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const volume = bundle.volumes.find((v: VolumeRow) => v.id === parsed.data.volumeId)
if (!volume) return res.status(404).json({ error: '卷不存在' })
// 起始编号 = 此前所有卷的细纲章数之和 + 1;前情概要 = 此前各卷细纲标题序列
const sortedVolumes = [...bundle.volumes].sort((a, b) => a.sort - b.sort)
const volumeIdx = sortedVolumes.findIndex((v) => v.id === volume.id)
const prevVolumes = sortedVolumes.slice(0, volumeIdx)
const startNo = prevVolumes.reduce((acc, v) => acc + bundle.beats.filter((b: BeatParsed) => b.volume_id === v.id).length, 0) + 1
const prevRecap = prevVolumes
.map((v) => {
const volBeats = bundle.beats.filter((b: BeatParsed) => b.volume_id === v.id).sort((a, b) => a.sort - b.sort)
return `《${v.title}》:${volBeats.map((b) => b.title.replace(/^第\d+章\s*/, '')).join('、')}`
})
.join('\n')
// 本卷目标章数:应用大纲时创建的空章节数;没有章节信息时默认 10
const existing = bundle.chapters.filter((c: ChapterRow) => c.volume_id === volume.id).length
const chapterCount = existing > 0 ? existing : 10
const messages = beatsMessages(
toProjectLike(bundle.project),
{ title: volume.title, summary: volume.summary, chapter_count: chapterCount },
bundle.characters,
bundle.world,
prevRecap
)
messages[1] = {
role: 'user',
content: `${messages[1].content}\n\n【编号要求】章节从第${startNo}章开始连续编号;共 ${chapterCount} 章,不可多不可少。`
}
void structuredStage(req, res, 'beats', bundle.project.id, withRequirement(messages, parsed.data.requirement), (t) =>
dedupeByTitle(parseStructured(t, beatArraySchema), (b) => b.title, 'beat')
)
})
// ---------- 正文(流式) ----------
generateRouter.post('/generate/prose', (req, res) => {
const parsed = z.object({ projectId: z.number().int(), chapterId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const chapter = bundle.chapters.find((c: ChapterRow) => c.id === parsed.data.chapterId)
if (!chapter) return res.status(404).json({ error: '章节不存在' })
const ctx = buildProseContext(bundle, chapter)
void streamStage(req, res, 'prose', bundle.project.id, withRequirement(proseMessages(ctx), parsed.data.requirement))
})
// ---------- 段落重写(流式) ----------
generateRouter.post('/generate/rewrite', (req, res) => {
const parsed = z
.object({
projectId: z.number().int(),
mode: z.enum(['rewrite', 'expand', 'shrink', 'custom']),
instruction: z.string().max(1000).optional(),
selected: z.string().min(1).max(20000),
before: z.string().max(1000).default(''),
after: z.string().max(1000).default('')
})
.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
void streamStage(req, res, `rewrite:${parsed.data.mode}`, parsed.data.projectId, rewriteMessages(parsed.data))
})
// ================= 采纳落库 =================
// ---------- 为旧人物卡补写专业字段(声口 / 内在冲突 / 秘密),只填空字段不覆盖已填 ----------
generateRouter.post('/generate/backfill/characters', async (req, res) => {
const parsed = z.object({ projectId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const targets = bundle.characters.filter((c) => !c.speech_pattern.trim() || !c.inner_conflict.trim() || !c.secret.trim())
if (targets.length === 0) return res.json({ updated: 0, message: '所有人物卡均已补全' })
const cfg = getLlmConfig()
const messages = withRequirement(
characterBackfillMessages(
targets.map((c) => ({ name: c.name, role: c.role, aliases: c.aliases, personality: c.personality, background: c.background, goals: c.goals })),
toProjectLike(bundle.project)
),
parsed.data.requirement
)
try {
const { data, usage } = await chatStructured(cfg, messages, (t) => parseStructured(t, characterBackfillArraySchema), {
signal: AbortSignal.timeout(180000)
})
let updated = 0
db.exec('BEGIN')
try {
for (const item of data) {
const hit = targets.find((c) => c.name === item.name || (c.aliases && c.aliases.split(/[、,,;;\s]+/).includes(item.name)))
if (!hit) continue
const sets: string[] = []
const vals: SQLInputValue[] = []
if (!hit.speech_pattern.trim() && item.speech_pattern.trim()) {
sets.push('speech_pattern=?')
vals.push(item.speech_pattern)
}
if (!hit.inner_conflict.trim() && item.inner_conflict.trim()) {
sets.push('inner_conflict=?')
vals.push(item.inner_conflict)
}
if (!hit.secret.trim() && item.secret.trim()) {
sets.push('secret=?')
vals.push(item.secret)
}
if (sets.length === 0) continue
vals.push(now(), hit.id)
db.prepare(`UPDATE characters SET ${sets.join(', ')}, updated_at=? WHERE id=?`).run(...vals)
updated++
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
bundle.project.id,
'backfill:characters',
cfg.model,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
now()
)
res.json({ updated, filled: data.length })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(bundle.project.id, 'backfill:characters', cfg.model, now())
res.status(500).json({ error: msg })
}
})
const applyCharacters = z.object({ projectId: z.number().int(), items: characterCardArraySchema })
generateRouter.post('/generate/apply/characters', (req, res) => {
const parsed = applyCharacters.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '人物数据格式错误' })
const { projectId, items } = parsed.data
const base = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM characters WHERE project_id = ?').get(projectId) as { m: number }).m
db.exec('BEGIN')
try {
items.forEach((c, i) => {
db.prepare(
'INSERT INTO characters (project_id, name, aliases, role, appearance, personality, background, goals, speech_pattern, inner_conflict, secret, arc_notes, sort, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
).run(
projectId,
c.name,
c.aliases,
c.role,
c.appearance,
c.personality,
c.background,
c.goals,
c.speech_pattern,
c.inner_conflict,
c.secret,
c.arc_notes,
base + i + 1,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyWorld = z.object({ projectId: z.number().int(), items: worldEntryArraySchema })
generateRouter.post('/generate/apply/world', (req, res) => {
const parsed = applyWorld.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '设定数据格式错误' })
const { projectId, items } = parsed.data
const base = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM world_entries WHERE project_id = ?').get(projectId) as { m: number }).m
db.exec('BEGIN')
try {
items.forEach((w, i) => {
db.prepare('INSERT INTO world_entries (project_id, category, title, content, sort, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
w.category,
w.title,
w.content,
base + i + 1,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyVolumes = z.object({ projectId: z.number().int(), items: volumeArraySchema })
generateRouter.post('/generate/apply/volumes', (req, res) => {
const parsed = applyVolumes.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '大纲数据格式错误' })
const { projectId } = parsed.data
const items = dedupeByTitle(parsed.data.items, (v) => v.title, 'vol')
// 重建大纲:删除旧卷与细纲;无内容的空章节一并清除,已写正文的章节保留为「未分卷」
db.exec('BEGIN')
try {
db.prepare("DELETE FROM chapters WHERE project_id = ? AND status = 'empty'").run(projectId)
db.prepare('DELETE FROM beats WHERE project_id = ?').run(projectId)
db.prepare('DELETE FROM volumes WHERE project_id = ?').run(projectId)
items.forEach((v, i) => {
db.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)').run(projectId, v.title, v.summary, i + 1)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyBeats = z.object({ projectId: z.number().int(), volumeId: z.number().int(), items: beatArraySchema })
generateRouter.post('/generate/apply/beats', (req, res) => {
const parsed = applyBeats.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '细纲数据格式错误' })
const { projectId, volumeId } = parsed.data
const items = dedupeByTitle(parsed.data.items, (b) => b.title, 'beat')
const bundle = loadBundle(projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const volume = bundle.volumes.find((v: VolumeRow) => v.id === volumeId)
if (!volume) return res.status(404).json({ error: '卷不存在' })
// 全书全局排序:此前各卷章数之和作为本卷起始序号
const sortedVolumes = [...bundle.volumes].sort((a, b) => a.sort - b.sort)
const beforeCount = sortedVolumes
.slice(0, sortedVolumes.findIndex((v) => v.id === volumeId))
.reduce((acc, v) => acc + bundle.chapters.filter((c: ChapterRow) => c.volume_id === v.id).length, 0)
db.exec('BEGIN')
try {
db.prepare('DELETE FROM beats WHERE volume_id = ?').run(volumeId)
// 清掉本卷空章节(内容章节保留,beat 关联自动断开)
db.prepare("DELETE FROM chapters WHERE volume_id = ? AND status = 'empty'").run(volumeId)
items.forEach((b, i) => {
const involvedIds = mapNamesToIds(b.involved_characters, bundle.characters)
const info = db
.prepare(
'INSERT INTO beats (project_id, volume_id, sort, title, events, involved_characters, foreshadow_plan, pov, emotional_arc, purpose, tension) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(projectId, volumeId, i + 1, b.title, b.events, JSON.stringify(involvedIds), b.foreshadow_plan, b.pov ?? '', b.emotional_arc ?? '', b.purpose ?? '', b.tension ?? 0)
db.prepare('INSERT INTO chapters (project_id, beat_id, volume_id, sort, title, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
info.lastInsertRowid,
volumeId,
beforeCount + i + 1,
b.title,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
+119
View File
@@ -0,0 +1,119 @@
import { Router } from 'express'
import { z } from 'zod'
import { db } from '../db/connection.js'
import { parseInvolved } from '../db/types.js'
export const outlineRouter = Router()
// ---------- 分卷 ----------
const volumeInput = z.object({
title: z.string().min(1).max(100),
summary: z.string().max(4000).default(''),
sort: z.number().int().default(0)
})
outlineRouter.post('/projects/:projectId/volumes', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = volumeInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const info = db
.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)')
.run(projectId, parsed.data.title, parsed.data.summary, parsed.data.sort)
res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(info.lastInsertRowid))
})
outlineRouter.patch('/volumes/:id', (req, res) => {
const parsed = volumeInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined)
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
db.prepare(`UPDATE volumes SET ${entries.map(([c]) => `${c} = ?`).join(', ')} WHERE id = ?`).run(
...entries.map(([, v]) => v),
Number(req.params.id)
)
res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(Number(req.params.id)))
})
// 删除卷:细纲与章节保留(volume_id 置空)
outlineRouter.delete('/volumes/:id', (req, res) => {
db.prepare('DELETE FROM volumes WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
// ---------- 分章细纲 ----------
const beatInput = z.object({
volume_id: z.number().int().nullable().default(null),
title: z.string().min(1).max(120),
events: z.string().max(12000).default(''),
involved_characters: z.array(z.number().int()).default([]),
foreshadow_plan: z.string().max(4000).default(''),
pov: z.string().max(60).default(''),
emotional_arc: z.string().max(200).default(''),
purpose: z.string().max(30).default(''),
tension: z.number().int().min(0).max(5).default(0)
})
outlineRouter.post('/projects/:projectId/beats', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = beatInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const maxSort = db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM beats WHERE project_id = ?').get(projectId) as { m: number }
const sort = d.volume_id
? (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM beats WHERE volume_id = ?').get(d.volume_id) as { m: number }).m + 1
: maxSort.m + 1
const info = db
.prepare(
'INSERT INTO beats (project_id, volume_id, sort, title, events, involved_characters, foreshadow_plan, pov, emotional_arc, purpose, tension) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(projectId, d.volume_id, sort, d.title, d.events, JSON.stringify(d.involved_characters), d.foreshadow_plan, d.pov, d.emotional_arc, d.purpose, d.tension)
res.json(parseBeatRow(db.prepare('SELECT * FROM beats WHERE id = ?').get(info.lastInsertRowid)))
})
outlineRouter.patch('/beats/:id', (req, res) => {
const parsed = beatInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined) as [string, string | number | null][]
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
// involved_characters 以 JSON 字符串入库
const mapped = entries.map(([k, v]) => (k === 'involved_characters' ? (['involved_characters', JSON.stringify(v)] as const) : ([k, v] as const)))
db.prepare(`UPDATE beats SET ${mapped.map(([c]) => `${c} = ?`).join(', ')} WHERE id = ?`).run(
...mapped.map(([, v]) => v),
Number(req.params.id)
)
res.json(parseBeatRow(db.prepare('SELECT * FROM beats WHERE id = ?').get(Number(req.params.id))))
})
/** 出站形态:involved_characters 解析为数组 */
function parseBeatRow(row: unknown) {
const r = row as { involved_characters: string } | undefined
if (!r) return row
try {
const arr = JSON.parse(r.involved_characters)
return { ...r, involved_characters: Array.isArray(arr) ? arr : [] }
} catch {
return { ...r, involved_characters: [] }
}
}
outlineRouter.delete('/beats/:id', (req, res) => {
db.prepare('DELETE FROM beats WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
/** 工具:把模型输出的涉及人物名映射为人物 id */
export function mapNamesToIds(names: string[], characters: { id: number; name: string; aliases: string }[]): number[] {
const ids: number[] = []
for (const raw of names) {
const name = String(raw).trim()
if (!name) continue
const hit =
characters.find((c) => c.name === name) ??
characters.find((c) => c.aliases && c.aliases.split(/[、,,;;\s]+/).includes(name)) ??
characters.find((c) => c.name.includes(name) || name.includes(c.name))
if (hit && !ids.includes(hit.id)) ids.push(hit.id)
}
return ids
}
export { parseInvolved }
+80
View File
@@ -0,0 +1,80 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { loadBundle } from '../pipeline/context.js'
export const projectsRouter = Router()
const projectInput = z.object({
name: z.string().min(1, '作品名不能为空').max(200),
genre: z.string().max(100).default(''),
premise: z.string().max(60000, '故事创意过长(上限 6 万字符)').default(''),
style_notes: z.string().max(20000).default(''),
style_sample: z.string().max(40000).default(''),
narrative_pov: z.string().max(100).default(''),
taboos: z.string().max(2000).default(''),
target_chapter_words: z.number().int().min(200).max(20000).default(3000),
target_chapter_count: z.number().int().min(1).max(2000).default(30)
})
projectsRouter.get('/projects', (_req, res) => {
const rows = db
.prepare(
`SELECT p.*,
COALESCE((SELECT SUM(word_count) FROM chapters c WHERE c.project_id = p.id), 0) AS total_words,
(SELECT COUNT(*) FROM chapters c WHERE c.project_id = p.id) AS chapter_count,
(SELECT COUNT(*) FROM chapters c WHERE c.project_id = p.id AND c.content != '') AS written_count
FROM projects p ORDER BY p.updated_at DESC`
)
.all()
res.json(rows)
})
projectsRouter.post('/projects', (req, res) => {
const parsed = projectInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const t = now()
const r = db
.prepare(
'INSERT INTO projects (name, genre, premise, style_notes, style_sample, narrative_pov, taboos, target_chapter_words, target_chapter_count, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(d.name, d.genre, d.premise, d.style_notes, d.style_sample, d.narrative_pov, d.taboos, d.target_chapter_words, d.target_chapter_count, t, t)
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(r.lastInsertRowid))
})
projectsRouter.patch('/projects/:id', (req, res) => {
const id = Number(req.params.id)
const parsed = projectInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const cur = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Record<string, unknown> | undefined
if (!cur) return res.status(404).json({ error: '项目不存在' })
const d = { ...cur, ...parsed.data, updated_at: now() }
db.prepare(
'UPDATE projects SET name=?, genre=?, premise=?, style_notes=?, style_sample=?, narrative_pov=?, taboos=?, target_chapter_words=?, target_chapter_count=?, updated_at=? WHERE id=?'
).run(
d.name as string,
d.genre as string,
d.premise as string,
d.style_notes as string,
d.style_sample as string,
d.narrative_pov as string,
d.taboos as string,
d.target_chapter_words as number,
d.target_chapter_count as number,
d.updated_at as string,
id
)
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(id))
})
projectsRouter.delete('/projects/:id', (req, res) => {
db.prepare('DELETE FROM projects WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
projectsRouter.get('/projects/:id/bundle', (req, res) => {
const bundle = loadBundle(Number(req.params.id))
if (!bundle) return res.status(404).json({ error: '项目不存在' })
res.json(bundle)
})
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { getLlmConfig } from '../db/seed.js'
import { testConnection } from '../llm/client.js'
export const settingsRouter = Router()
const settingsInput = z.object({
base_url: z.string().url().max(300),
api_key: z.string().max(300).default(''),
model: z.string().min(1).max(200),
temperature: z.number().min(0).max(2).default(0.8),
max_tokens: z.number().int().min(256).max(65536).default(12000)
})
settingsRouter.get('/settings', (_req, res) => {
const cfg = getLlmConfig()
res.json({ ...cfg, id: 1, updated_at: now() })
})
settingsRouter.put('/settings', (req, res) => {
const parsed = settingsInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
db.prepare('UPDATE llm_config SET base_url=?, api_key=?, model=?, temperature=?, max_tokens=?, updated_at=? WHERE id=1').run(
d.base_url,
d.api_key,
d.model,
d.temperature,
d.max_tokens,
now()
)
res.json(getLlmConfig())
})
settingsRouter.post('/settings/test', async (req, res) => {
const parsed = z.object({ base_url: z.string().url(), api_key: z.string(), model: z.string().min(1) }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
try {
const result = await testConnection(parsed.data)
res.json({ ok: true, ...result })
} catch (e) {
res.json({ ok: false, error: e instanceof Error ? e.message : String(e) })
}
})
+103
View File
@@ -0,0 +1,103 @@
import JSZip from 'jszip'
export interface EpubChapter {
volumeTitle?: string
title: string
paragraphs: string[]
}
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
const CSS = `body{font-family:serif;line-height:1.8;margin:1em}h1{font-size:1.4em;text-align:center}h2{font-size:1.2em;text-align:center}p{text-indent:2em;margin:0.5em 0}`
/** 手工构建 EPUB3(mimetype 首位 + 容器 + OPF + 导航 + 章节),零不确定依赖 */
export async function buildEpub(title: string, chapters: EpubChapter[]): Promise<Buffer> {
const zip = new JSZip()
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
zip.file(
'META-INF/container.xml',
`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
)
zip.file('OEBPS/style.css', CSS)
// 卷分组导航
const groups: { volume?: string; chapters: { idx: number; title: string }[] }[] = []
for (let i = 0; i < chapters.length; i++) {
const c = chapters[i]
const last = groups[groups.length - 1]
if (last && last.volume === c.volumeTitle) last.chapters.push({ idx: i, title: c.title })
else groups.push({ volume: c.volumeTitle, chapters: [{ idx: i, title: c.title }] })
}
const chapterFiles = chapters.map((_, i) => `chapter_${String(i + 1).padStart(4, '0')}.xhtml`)
chapters.forEach((c, i) => {
const volLine = c.volumeTitle ? `<p class="volume" style="text-align:center;color:#666">${esc(c.volumeTitle)}</p>` : ''
zip.file(
`OEBPS/${chapterFiles[i]}`,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>${esc(c.title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body><h2>${esc(c.title)}</h2>${volLine}${c.paragraphs.map((p) => `<p>${esc(p)}</p>`).join('')}</body></html>`
)
})
const navLis = groups
.map((g) => {
const label = esc(g.volume || '正文')
const items = g.chapters
.map((ch) => `<li><a href="${chapterFiles[ch.idx]}">${esc(ch.title)}</a></li>`)
.join('')
return g.volume ? `<li><span>${label}</span><ol>${items}</ol></li>` : items
})
.join('')
zip.file(
'OEBPS/nav.xhtml',
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>目录</title></head>
<body><nav epub:type="toc" id="toc"><h1>目录</h1><ol>${navLis}</ol></nav></body></html>`
)
zip.file(
'OEBPS/title.xhtml',
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>${esc(title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body style="text-align:center;padding-top:8em"><h1>${esc(title)}</h1></body></html>`
)
const manifest = [
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
'<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>',
'<item id="css" href="style.css" media-type="text/css"/>',
...chapterFiles.map((f, i) => `<item id="ch${i + 1}" href="${f}" media-type="application/xhtml+xml"/>`)
].join('\n ')
const spine = ['<itemref idref="title"/>', '<itemref idref="nav"/>', ...chapterFiles.map((_, i) => `<itemref idref="ch${i + 1}"/>`)].join('\n ')
const uuid = crypto.randomUUID()
zip.file(
'OEBPS/content.opf',
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="zh-CN">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">urn:uuid:${uuid}</dc:identifier>
<dc:title>${esc(title)}</dc:title>
<dc:language>zh-CN</dc:language>
<meta property="dcterms:modified">${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}</meta>
</metadata>
<manifest>
${manifest}
</manifest>
<spine>
${spine}
</spine>
</package>`
)
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
}
+24
View File
@@ -0,0 +1,24 @@
import type { ZodTypeAny, z } from 'zod'
/** 从模型输出中宽松地提取 JSON(容忍 markdown 代码块、前后缀文字) */
export function parseJsonLoose(text: string): unknown {
let t = text.trim()
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fence) t = fence[1].trim()
const starts = ['{', '['].map((c) => t.indexOf(c)).filter((i) => i !== -1)
if (starts.length === 0) throw new Error('输出中未找到 JSON')
const start = Math.min(...starts)
const end = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'))
if (end <= start) throw new Error('JSON 不完整')
return JSON.parse(t.slice(start, end + 1))
}
/** 解析 + zod 校验,失败抛出可读错误 */
export function parseStructured<S extends ZodTypeAny>(text: string, schema: S): z.output<S> {
const raw = parseJsonLoose(text)
const parsed = schema.safeParse(raw)
if (!parsed.success) {
throw new Error('JSON 结构校验失败: ' + parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')} ${i.message}`).join('; '))
}
return parsed.data
}
+15
View File
@@ -0,0 +1,15 @@
import type { Response } from 'express'
export function sseInit(res: Response) {
res.status(200).set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
res.flushHeaders?.()
}
export function sseSend(res: Response, event: string, data: unknown) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
+4
View File
@@ -0,0 +1,4 @@
/** 中文友好的字数统计:去除所有空白后的字符数 */
export function countWords(s: string): number {
return [...s.replace(/\s+/g, '')].length
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}