AI 小说生成工作台:分层流水线 + 故事圣经(一期完整实现)
- 后端:Node/Express + node:sqlite,人物/世界观/分卷/细纲/正文 8 阶段生成流水线(SSE 流式 + zod 校验 + 重试) - 前端:React 18 + Vite + antd 三栏工作台:流水线引导、章节树、编辑器(快切章节/段落改写)、故事圣经面板 - 写作工艺注入:人物声口/内在冲突/秘密,细纲 POV/情绪曲线/功能/张力,正文专业规则 - 自动回写故事圣经(摘要/设定/人物变化/伏笔/时间线),TXT/EPUB 导出,token 成本日志
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface EpubChapter {
|
||||
volumeTitle?: string
|
||||
title: string
|
||||
paragraphs: string[]
|
||||
}
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
|
||||
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' })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 中文友好的字数统计:去除所有空白后的字符数 */
|
||||
export function countWords(s: string): number {
|
||||
return [...s.replace(/\s+/g, '')].length
|
||||
}
|
||||
Reference in New Issue
Block a user