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

- 后端:Node/Express + node:sqlite,人物/世界观/分卷/细纲/正文 8 阶段生成流水线(SSE 流式 + zod 校验 + 重试)
- 前端:React 18 + Vite + antd 三栏工作台:流水线引导、章节树、编辑器(快切章节/段落改写)、故事圣经面板
- 写作工艺注入:人物声口/内在冲突/秘密,细纲 POV/情绪曲线/功能/张力,正文专业规则
- 自动回写故事圣经(摘要/设定/人物变化/伏笔/时间线),TXT/EPUB 导出,token 成本日志
This commit is contained in:
Administrator
2026-09-13 20:35:47 +08:00
commit 4073edcc20
49 changed files with 9827 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_API_KEY=你的APIKey
ARK_MODEL=doubao-seed-1-6-flash
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
server/data/
web/dist/
.env
*.log
.DS_Store
.zcode/
+81
View File
@@ -0,0 +1,81 @@
# 智能小说工作台
从一句话创意到完整长篇的 AI 小说生成工作台。核心设计:**分层流水线 + 故事圣经**——AI 不会一次生成整本书,而是沿「人物卡 → 世界观 → 分卷大纲 → 分章细纲 → 逐章正文」推进,每一步产物都可编辑确认;正文生成时自动注入相关人物卡、前情摘要与未回收伏笔,写完后自动把摘要、新设定、人物变化、伏笔、时间线回写进"故事圣经",保证长篇的前后一致性。
## 功能
- **项目管理**:作品名 / 题材 / 故事创意 / 文风要求与示例 / 目标规模,卡片式总览,字数与进度统计
- **人物卡 & 世界观**:AI 生成(可反复重生成),全部字段可编辑;生成正文后自动追加人物近况
- **大纲与细纲**:AI 生成分卷大纲、每卷分章细纲(事件 / 涉及人物 / 伏笔安排),均可编辑
- **逐章正文生成**:流式输出(SSE),可中断;上下文注入 = 本章细纲 + 涉及人物完整卡 + 相关设定 + 全部前章摘要 + 上一章结尾 500 字 + 未回收伏笔
- **段落级改写**:选中任意段落重写 / 扩写 / 缩写 / 自定义指令,结果确认后替换
- **故事圣经面板**:人物 / 世界观 / 伏笔(未回收·已回收)/ 时间线,右侧栏常驻可直接增删改
- **自动回写**:每章正文采纳后,AI 自动提取章节摘要、新设定、人物状态变化、埋设/回收的伏笔、时间线事件
- **导出**:TXT(含卷章结构)与 EPUB(EPUB 3,目录按卷分组)
- **生成日志**:记录每次调用的模型与 token 用量(`generation_logs` 表)
## 快速开始
```bash
npm install # 已安装过可跳过
npm run dev # 开发模式:API 127.0.0.1:5175 + 前端 127.0.0.1:5173(自动代理)
```
生产模式(单端口,服务同时托管前端页面):
```bash
npm run build
npm start # 打开 http://127.0.0.1:5175
```
数据存放在 `server/data/novel.db`(SQLite 单文件,WAL 模式),备份即复制该文件。
## 模型接入(重要)
支持任意 **OpenAI 兼容接口**。首次启动会从 `.env` 读取初始配置,之后在界面右上角「模型设置」中查看修改,可一键「测试连接」。
当前 `.env` 预置了火山方舟配置:
```
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/plan/v3
ARK_API_KEY=ark-xxxx # 你的 API Key
ARK_MODEL=doubao-seed-2.0-lite
```
当前 `.env` 已配置火山方舟 Agent Plan 并实测连通:
```
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/plan/v3
ARK_API_KEY=ark-xxxx
ARK_MODEL=doubao-seed-2.0-lite
```
> 配置以界面「模型设置」中保存的为准(存在数据库里)。若要换模型,注意 Agent Plan 只支持套餐内模型(如 `doubao-seed-2.0-lite`),标准后付费模型名会报 UnsupportedModel。应用会自动关闭豆包 seed 系列的深度思考以提速并保证输出干净。
## 目录结构
```
├── server/ # Node.js + Express + node:sqlite
│ └── src/
│ ├── db/ # 建表 / 种子 / 行类型
│ ├── routes/ # CRUD、生成(SSE)、导出、设置
│ ├── llm/ # OpenAI 兼容客户端 + 各阶段提示词
│ └── pipeline/ # 上下文组装 + 圣经回写
├── web/ # React 18 + Vite + Ant Design
│ └── src/components/ # 章节树 / 编辑器 / 生成抽屉 / 故事圣经 等
├── scripts/e2e_test.py # 端到端回归测试(python -X utf8 scripts/e2e_test.py,需服务已启动)
└── .env # 模型配置(已 gitignore,勿提交)
```
## 设计说明
- **为什么不是一键生成整本**:长篇的难点是一致性(人物/伏笔/设定),分层生成 + 中间产物人工确认,比"一把梭"质量高得多,也便宜得多(改大纲比重写正文便宜)。
- **上下文策略**:MVP 采用"滚动摘要 + 相关实体注入",可稳定支撑数百章;向量检索留作二期增强。
- **模型容错**:自动兼容豆包 seed 系列的 thinking 参数(400 时降级重试)、JSON 输出解析失败自动带反馈重试(最多 3 次)。
## 常见问题
- **生成报 401 / AuthenticationError**:API Key 无效,去「模型设置」更换。
- **生成报 UnsupportedModel**:Agent Plan Key 只能用套餐内模型名(当前可用:`doubao-seed-2.0-lite`)。
- **生成报 max_tokens 相关错误**:模型输出上限小于设置值,调小「单次最大输出 tokens」。
- **端口号**:默认 API 5175、前端开发端口 5173,可在 `server/src/index.ts` / `web/vite.config.ts` 修改。
+4960
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "novel-workbench",
"private": true,
"version": "0.1.0",
"workspaces": [
"server",
"web"
],
"scripts": {
"dev": "concurrently -n server,web -c blue,green \"npm run dev -w server\" \"npm run dev -w web\"",
"build": "npm run build -w server && npm run build -w web",
"start": "npm run start -w server"
},
"devDependencies": {
"concurrently": "^9.1.0"
}
}
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""端到端回归:纯 UTF-8 中文数据走完整 CRUD + 导出链路"""
import json
import urllib.request
import zipfile
import io
import sys
BASE = 'http://127.0.0.1:5175'
FAILED = []
def req(path, method='GET', body=None, raw=False):
data = json.dumps(body, ensure_ascii=False).encode('utf-8') if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method)
if data:
r.add_header('Content-Type', 'application/json')
out = urllib.request.urlopen(r, timeout=30).read()
return out if raw else json.loads(out.decode('utf-8'))
def check(name, cond):
print(('PASS ' if cond else 'FAIL ') + name)
if not cond:
FAILED.append(name)
# 1. 建项目
p = req('/api/projects', 'POST', {
'name': '星海谎言', 'genre': '科幻',
'premise': '世代飞船上的工程师发现航行早已结束,航行本身是统治阶层的谎言',
'style_notes': '冷峻克制,多对话', 'target_chapter_count': 12, 'target_chapter_words': 2000,
})
pid = p['id']
check('project name roundtrip', p['name'] == '星海谎言')
check('premise roundtrip', p['premise'].startswith('世代飞船'))
# 2. 人物 / 世界观
c = req(f'/api/projects/{pid}/characters', 'POST', {
'name': '林原', 'aliases': '老林', 'role': '主角',
'personality': '沉稳多疑,对机械有近乎信仰的执着', 'goals': '查明航线数据的真相',
'appearance': '瘦高,左眼有一道旧疤', 'background': '三代轮机世家,父亲死于一次"意外"检修事故',
})
check('character roundtrip', c['name'] == '林原' and c['background'].startswith('三代轮机世家'))
cid = c['id']
w = req(f'/api/projects/{pid}/world', 'POST', {
'category': '地理', 'title': '世代飞船远望号', 'content': '全长十二公里,自持循环设计寿命五百年,实际已航行四百九十七年',
})
check('world roundtrip', w['title'] == '世代飞船远望号')
# 3. 卷 / 细纲
v = req(f'/api/projects/{pid}/volumes', 'POST', {'title': '第一卷 裂缝', 'summary': '发现数据异常到确认真相', 'sort': 1})
check('volume roundtrip', v['title'] == '第一卷 裂缝')
b = req(f'/api/projects/{pid}/beats', 'POST', {
'volume_id': v['id'], 'title': '第1章 异响', 'events': '林原在检修中发现被删除四百九十七次的航线修正记录',
'involved_characters': [cid], 'foreshadow_plan': '埋下船长知情暗示',
})
check('beat involved ids parsed to array', b['involved_characters'] == [cid])
# 4. 章节 + 内容 + 字数
ch = req(f'/api/projects/{pid}/chapters', 'POST', {
'beat_id': b['id'], 'volume_id': v['id'], 'title': '第1章 异响', 'sort': 1,
})
chid = ch['id']
content = '检修通道的灯又灭了。林原把手电咬在嘴里,手指穿过线缆,摸到了那块不该存在的芯片。\n\n数据像潮水一样涌进终端——航线修正记录,五百年间,被删除了四百九十七次。'
updated = req(f'/api/chapters/{chid}', 'PATCH', {'content': content, 'status': 'edited'})
check('word count correct', updated['word_count'] == len([ch for ch in content if not ch.isspace()]))
# 5. bundle 聚合
bundle = req(f'/api/projects/{pid}/bundle')
check('bundle beat involved is array', bundle['beats'][0]['involved_characters'] == [cid])
check('bundle counts', len(bundle['characters']) == 1 and len(bundle['world']) == 1 and len(bundle['chapters']) == 1)
# 6. 项目列表统计
lst = req('/api/projects')
me = [x for x in lst if x['id'] == pid][0]
check('project stats', me['total_words'] > 0 and me['written_count'] == 1)
# 7. 导出 TXT / EPUB
txt = req(f'/api/export/{pid}/txt', raw=True).decode('utf-8-sig')
check('txt contains title/body/volume', '星海谎言' in txt and '不该存在的芯片' in txt and '第一卷 裂缝' in txt)
epub_raw = req(f'/api/export/{pid}/epub', raw=True)
z = zipfile.ZipFile(io.BytesIO(epub_raw))
check('epub valid zip', z.testzip() is None)
check('epub mimetype stored first', z.getinfo('mimetype').compress_type == zipfile.ZIP_STORED)
nav = z.read('OEBPS/nav.xhtml').decode('utf-8')
ch1 = z.read('OEBPS/chapter_0001.xhtml').decode('utf-8')
check('epub nav contains chapter', '第1章 异响' in nav)
check('epub chapter content escaped+present', '不该存在的芯片' in ch1)
# 8. 生成接口的 SSE 错误路径:临时换成无效 key,应返回规范 error 事件而非崩溃,然后恢复原配置
try:
saved = req('/api/settings')
req('/api/settings', 'PUT', {**saved, 'api_key': 'sk-invalid-key-for-e2e'})
r = urllib.request.Request(BASE + '/api/generate/prose', data=json.dumps({
'projectId': pid, 'chapterId': chid
}).encode(), method='POST', headers={'Content-Type': 'application/json'})
body = urllib.request.urlopen(r, timeout=60).read().decode('utf-8')
check('SSE error event emitted for auth failure', 'event: error' in body and 'HTTP' in body)
finally:
req('/api/settings', 'PUT', saved)
check('llm config restored', req('/api/settings')['api_key'] == saved['api_key'])
print()
print('TOTAL FAILED:', len(FAILED))
sys.exit(1 if FAILED else 0)
+24
View File
@@ -0,0 +1,24 @@
{
"name": "server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jszip": "^3.10.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^24.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 数据文件固定放在 server/data/ 下,与源码/编译产物位置无关
const dataDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../data')
export const db: DatabaseSync = (() => {
mkdirSync(dataDir, { recursive: true })
const db = new DatabaseSync(path.join(dataDir, 'novel.db'))
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
return db
})()
export const now = () => new Date().toISOString()
+135
View File
@@ -0,0 +1,135 @@
import { db } from './connection.js'
export function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL,
temperature REAL NOT NULL DEFAULT 0.8,
max_tokens INTEGER NOT NULL DEFAULT 12000,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
genre TEXT NOT NULL DEFAULT '',
premise TEXT NOT NULL DEFAULT '',
style_notes TEXT NOT NULL DEFAULT '',
style_sample TEXT NOT NULL DEFAULT '',
target_chapter_words INTEGER NOT NULL DEFAULT 3000,
target_chapter_count INTEGER NOT NULL DEFAULT 30,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '配角',
appearance TEXT NOT NULL DEFAULT '',
personality TEXT NOT NULL DEFAULT '',
background TEXT NOT NULL DEFAULT '',
goals TEXT NOT NULL DEFAULT '',
arc_notes TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS world_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT '其他',
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS volumes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS beats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
volume_id INTEGER REFERENCES volumes(id) ON DELETE SET NULL,
sort INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
involved_characters TEXT NOT NULL DEFAULT '[]',
foreshadow_plan TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
beat_id INTEGER REFERENCES beats(id) ON DELETE SET NULL,
volume_id INTEGER REFERENCES volumes(id) ON DELETE SET NULL,
sort INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'empty',
summary TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS foreshadows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
description TEXT NOT NULL,
planted_chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
paid_chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'planted',
notes TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS timeline_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_id INTEGER REFERENCES chapters(id) ON DELETE SET NULL,
story_time TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS generation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
ok INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
`)
// 增量迁移:为旧库补齐新增列(写作专业字段)
const addColumns: [string, string, string][] = [
['projects', 'narrative_pov', "ALTER TABLE projects ADD COLUMN narrative_pov TEXT NOT NULL DEFAULT ''"],
['projects', 'taboos', "ALTER TABLE projects ADD COLUMN taboos TEXT NOT NULL DEFAULT ''"],
['characters', 'speech_pattern', "ALTER TABLE characters ADD COLUMN speech_pattern TEXT NOT NULL DEFAULT ''"],
['characters', 'inner_conflict', "ALTER TABLE characters ADD COLUMN inner_conflict TEXT NOT NULL DEFAULT ''"],
['characters', 'secret', "ALTER TABLE characters ADD COLUMN secret TEXT NOT NULL DEFAULT ''"],
['beats', 'pov', "ALTER TABLE beats ADD COLUMN pov TEXT NOT NULL DEFAULT ''"],
['beats', 'emotional_arc', "ALTER TABLE beats ADD COLUMN emotional_arc TEXT NOT NULL DEFAULT ''"],
['beats', 'purpose', "ALTER TABLE beats ADD COLUMN purpose TEXT NOT NULL DEFAULT ''"],
['beats', 'tension', 'ALTER TABLE beats ADD COLUMN tension INTEGER NOT NULL DEFAULT 0']
]
for (const [table, col, ddl] of addColumns) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[]
if (!cols.some((c) => c.name === col)) db.exec(ddl)
}
}
+39
View File
@@ -0,0 +1,39 @@
import dotenv from 'dotenv'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { db, now } from './connection.js'
// 从 src/db 或 dist/db 出发向上 3 层都是项目根目录
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
dotenv.config({ path: path.join(rootDir, '.env') })
export interface LlmConfig {
base_url: string
api_key: string
model: string
temperature: number
max_tokens: number
}
export function seedLlmConfig() {
const row = db.prepare('SELECT id FROM llm_config WHERE id = 1').get()
if (row) return
db.prepare(
'INSERT INTO llm_config (id, base_url, api_key, model, temperature, max_tokens, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?)'
).run(
process.env.ARK_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3',
process.env.ARK_API_KEY || '',
process.env.ARK_MODEL || 'doubao-seed-1-6-flash',
0.8,
12000,
now()
)
}
export function getLlmConfig(): LlmConfig {
const row = db.prepare('SELECT base_url, api_key, model, temperature, max_tokens FROM llm_config WHERE id = 1').get() as
| LlmConfig
| undefined
if (!row) throw new Error('LLM 配置缺失,请先启动初始化')
return row
}
+112
View File
@@ -0,0 +1,112 @@
export interface ProjectRow {
id: number
name: string
genre: string
premise: string
style_notes: string
style_sample: string
narrative_pov: string
taboos: string
target_chapter_words: number
target_chapter_count: number
created_at: string
updated_at: string
}
export interface CharacterRow {
id: number
project_id: number
name: string
aliases: string
role: string
appearance: string
personality: string
background: string
goals: string
speech_pattern: string
inner_conflict: string
secret: string
arc_notes: string
sort: number
updated_at: string
}
export interface WorldEntryRow {
id: number
project_id: number
category: string
title: string
content: string
sort: number
updated_at: string
}
export interface VolumeRow {
id: number
project_id: number
title: string
summary: string
sort: number
}
export interface BeatRow {
id: number
project_id: number
volume_id: number | null
sort: number
title: string
events: string
involved_characters: string // JSON array of character ids(数据库原始存储)
foreshadow_plan: string
pov: string
emotional_arc: string
purpose: string
tension: number
}
/** 出站形态:involved_characters 已解析为 id 数组 */
export type BeatParsed = Omit<BeatRow, 'involved_characters'> & { involved_characters: number[] }
export interface ChapterRow {
id: number
project_id: number
beat_id: number | null
volume_id: number | null
sort: number
title: string
content: string
status: string
summary: string
word_count: number
updated_at: string
}
export interface ForeshadowRow {
id: number
project_id: number
description: string
planted_chapter_id: number | null
paid_chapter_id: number | null
status: string
notes: string
updated_at: string
}
export interface TimelineEventRow {
id: number
project_id: number
chapter_id: number | null
story_time: string
title: string
description: string
sort: number
}
export function parseInvolved(json: string): number[] {
try {
const arr = JSON.parse(json)
return Array.isArray(arr) ? arr.map(Number).filter((n) => Number.isInteger(n)) : []
} catch {
return []
}
}
+52
View File
@@ -0,0 +1,52 @@
import express from 'express'
import cors from 'cors'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { migrate } from './db/schema.js'
import { seedLlmConfig } from './db/seed.js'
import { projectsRouter } from './routes/projects.js'
import { assetsRouter } from './routes/assets.js'
import { outlineRouter } from './routes/outline.js'
import { chaptersRouter } from './routes/chapters.js'
import { settingsRouter } from './routes/settings.js'
import { generateRouter } from './routes/generate.js'
import { exportRouter } from './routes/export.js'
migrate()
seedLlmConfig()
const app = express()
app.use(cors())
app.use(express.json({ limit: '10mb' }))
app.use('/api', projectsRouter)
app.use('/api', assetsRouter)
app.use('/api', outlineRouter)
app.use('/api', chaptersRouter)
app.use('/api', settingsRouter)
app.use('/api', generateRouter)
app.use('/api', exportRouter)
app.use('/api', (_req, res) => res.status(404).json({ error: '接口不存在' }))
// 生产模式:托管前端构建产物(SPA 回退)
const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../web/dist')
if (existsSync(webDist)) {
app.use(express.static(webDist))
app.use((req, res, next) => {
if (req.method === 'GET' && !req.path.startsWith('/api')) return res.sendFile(path.join(webDist, 'index.html'))
next()
})
}
// 统一错误处理
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const msg = err instanceof Error ? err.message : String(err)
res.status(500).json({ error: msg })
})
const PORT = Number(process.env.PORT || 5175)
app.listen(PORT, '127.0.0.1', () => {
console.log(`[novel-workbench] API 服务已启动: http://127.0.0.1:${PORT}`)
})
+162
View File
@@ -0,0 +1,162 @@
import type { LlmConfig } from '../db/seed.js'
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface Usage {
prompt_tokens?: number
completion_tokens?: number
}
export interface ChatOpts {
onDelta?: (t: string) => void
signal?: AbortSignal
temperature?: number
maxTokens?: number
}
const joinUrl = (base: string, p: string) => base.replace(/\/+$/, '') + p
function buildBody(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts, extra: boolean) {
const body: Record<string, unknown> = {
model: cfg.model,
messages,
stream,
temperature: opts.temperature ?? cfg.temperature,
max_tokens: opts.maxTokens ?? cfg.max_tokens
}
if (extra) {
// 豆包 seed 系列默认可能开启深度思考;关闭以提速并保持输出干净。
// 不识别该参数的服务商会在 400 中提及,随后自动降级重试。
body.thinking = { type: 'disabled' }
if (stream) body.stream_options = { include_usage: true }
}
return body
}
async function doFetch(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts, extra: boolean) {
return fetch(joinUrl(cfg.base_url, '/chat/completions'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_key}` },
body: JSON.stringify(buildBody(cfg, messages, stream, opts, extra)),
signal: opts.signal
})
}
/** 带降级重试的请求:首次带 thinking disabled / usage 统计,400 时去掉附加参数重发 */
async function fetchResilient(cfg: LlmConfig, messages: ChatMessage[], stream: boolean, opts: ChatOpts) {
let res = await doFetch(cfg, messages, stream, opts, true)
if (!res.ok) {
const errText = await res.text()
if (res.status === 400 && /thinking|stream_options|unknown|unrecognized/i.test(errText)) {
res = await doFetch(cfg, messages, stream, opts, false)
if (!res.ok) throw new Error(`模型接口 HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`)
} else {
throw new Error(`模型接口 HTTP ${res.status}: ${errText.slice(0, 500)}`)
}
}
return res
}
/** 流式对话,逐段回调 onDelta,返回完整内容与用量 */
export async function chatStream(cfg: LlmConfig, messages: ChatMessage[], opts: ChatOpts = {}) {
const res = await fetchResilient(cfg, messages, true, opts)
if (!res.body) throw new Error('模型接口返回空流')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let content = ''
let usage: Usage | undefined
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (!line.startsWith('data:')) continue
const payload = line.slice(5).trim()
if (payload === '[DONE]') continue
try {
const json = JSON.parse(payload)
const piece: string = json.choices?.[0]?.delta?.content ?? ''
if (piece) {
content += piece
opts.onDelta?.(piece)
}
if (json.usage) usage = json.usage
const errMsg = json.error?.message
if (errMsg) throw new Error('模型流式返回错误: ' + errMsg)
} catch (e) {
if (e instanceof SyntaxError) continue // 忽略无法解析的行
throw e
}
}
}
return { content, usage }
}
/** 非流式对话 */
export async function chatOnce(cfg: LlmConfig, messages: ChatMessage[], opts: ChatOpts = {}) {
const res = await fetchResilient(cfg, messages, false, opts)
const json = await res.json()
const content: string = json.choices?.[0]?.message?.content ?? ''
if (!content) throw new Error('模型返回内容为空: ' + JSON.stringify(json).slice(0, 300))
return { content, usage: json.usage as Usage | undefined }
}
/** 结构化输出:模型 JSON 解析失败时携带错误反馈重试 */
export async function chatStructured<T>(
cfg: LlmConfig,
messages: ChatMessage[],
validate: (text: string) => T,
opts: ChatOpts = {}
): Promise<{ data: T; raw: string; usage?: Usage }> {
let lastErr: unknown
for (let attempt = 0; attempt < 3; attempt++) {
const msgs = [...messages]
if (attempt > 0 && lastErr instanceof Error) {
msgs.push({ role: 'assistant', content: lastErr.message.slice(0, 100) })
msgs.push({ role: 'user', content: `上次输出无法解析(${lastErr.message.slice(0, 200)})。请严格只输出符合要求的 JSON,不要任何其他文字。` })
}
const { content, usage } = await chatStream(cfg, msgs, {
...opts,
temperature: Math.min(opts.temperature ?? cfg.temperature, 0.7),
maxTokens: Math.min(opts.maxTokens ?? cfg.max_tokens, 16000)
})
try {
return { data: validate(content), raw: content, usage }
} catch (e) {
lastErr = e
}
}
throw new Error(`模型未能输出合法 JSON(已重试 3 次): ${lastErr instanceof Error ? lastErr.message : lastErr}`)
}
/** 设置页连通性测试:列模型 + 小对话 */
export async function testConnection(cfg: Pick<LlmConfig, 'base_url' | 'api_key' | 'model'>) {
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_key}` }
let models: string[] | undefined
try {
const r = await fetch(joinUrl(cfg.base_url, '/models'), { headers, signal: AbortSignal.timeout(15000) })
if (r.ok) {
const j = await r.json()
models = (j.data ?? []).map((m: { id: string }) => m.id).slice(0, 50)
}
} catch {
/* models 接口不可用不阻塞测试 */
}
const r = await fetch(joinUrl(cfg.base_url, '/chat/completions'), {
method: 'POST',
headers,
body: JSON.stringify({ model: cfg.model, messages: [{ role: 'user', content: '请只回复两个字:成功' }], max_tokens: 32, temperature: 0.1 }),
signal: AbortSignal.timeout(60000)
})
if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).slice(0, 400)}`)
const j = await r.json()
const reply: string = j.choices?.[0]?.message?.content ?? ''
return { models, reply: reply.trim().slice(0, 100) }
}
+362
View File
@@ -0,0 +1,362 @@
import { z } from 'zod'
import type { ChatMessage } from './client.js'
export interface ProjectLike {
name: string
genre: string
premise: string
style_notes: string
style_sample: string
narrative_pov: string
taboos: string
target_chapter_words: number
target_chapter_count: number
}
export const characterCardSchema = z.object({
name: z.string().min(1),
aliases: z.string().default(''),
role: z.string().default('配角'),
appearance: z.string().default(''),
personality: z.string().default(''),
background: z.string().default(''),
goals: z.string().default(''),
speech_pattern: z.string().default(''),
inner_conflict: z.string().default(''),
secret: z.string().default(''),
arc_notes: z.string().default('')
})
export const characterCardArraySchema = z.array(characterCardSchema).min(1)
export const worldEntrySchema = z.object({
category: z.string().default('其他'),
title: z.string().min(1),
content: z.string().default('')
})
export const worldEntryArraySchema = z.array(worldEntrySchema).min(1)
export const volumeSchema = z.object({
title: z.string().min(1),
summary: z.string().default(''),
chapter_count: z.number().int().min(1)
})
export const volumeArraySchema = z.array(volumeSchema).min(1)
export const beatSchema = z.object({
title: z.string().min(1),
events: z.string().default(''),
involved_characters: z.array(z.string()).default([]),
foreshadow_plan: z.string().default(''),
pov: z.string().default(''),
emotional_arc: z.string().default(''),
purpose: z.string().default(''),
tension: z.number().int().min(0).max(5).default(0)
})
export const beatArraySchema = z.array(beatSchema).min(1)
export const postprocessSchema = z.object({
summary: z.string().default(''),
new_world_facts: z.array(worldEntrySchema).default([]),
character_updates: z.array(z.object({ name: z.string(), update: z.string() })).default([]),
new_foreshadows: z.array(z.string()).default([]),
paid_foreshadow_ids: z.array(z.number().int()).default([]),
timeline_events: z
.array(z.object({ story_time: z.string().default(''), title: z.string(), description: z.string().default('') }))
.default([])
})
const JSON_ONLY = '\n只输出 JSON 本身:不要 markdown 代码块、不要解释、不要任何 JSON 以外的文字。'
function projectBrief(p: ProjectLike): string {
return [
`【作品名】《${p.name}》`,
`【题材】${p.genre || '未指定'}`,
`【故事创意/梗概】\n${p.premise || '(用户未填写,请依据作品名与题材合理发挥)'}`,
p.narrative_pov ? `【叙事视角】${p.narrative_pov}` : '',
p.taboos ? `【禁用内容】${p.taboos}(严禁出现)` : '',
p.style_notes ? `【文风要求】${p.style_notes}` : '',
`【目标规模】约 ${p.target_chapter_count} 章,每章约 ${p.target_chapter_words} 字`
]
.filter(Boolean)
.join('\n')
}
export function charactersMessages(p: ProjectLike): ChatMessage[] {
return [
{
role: 'system',
content: `你是一位资深中文小说编辑,擅长从故事创意中设计立体的人物。
请为这部作品设计 4-8 个相互关联、有戏剧张力的人物卡。要求:
- 人物覆盖核心冲突的各方(主角、盟友、对手、反派等),人物之间要有明确的情感或利益关系
- 性格鲜明独特、有优点也有缺陷,避免脸谱化;配角也要有独立动机
- goals 区分「表层目标」(人物自己想要的)与深层需求(人物真正缺失的),写入 goals 字段
- speech_pattern 写语言习惯:口癖、句式偏好、说话音量与节奏、面对冲突时的说话方式(供正文保持声口一致)
- inner_conflict 写内在冲突:欲望与恐惧、理智与情感的拉扯(好人物都由矛盾驱动)
- secret 写秘密:人物知道但不为人知的事,秘密迟早影响剧情(无则空字符串)
- role 只能取:主角、重要配角、配角、反派${JSON_ONLY}
数组元素格式:
{"name":"姓名","aliases":"昵称或别称,可为空字符串","role":"主角","appearance":"外貌与气质","personality":"性格特点","background":"背景经历","goals":"表层目标与深层需求","speech_pattern":"语言习惯与口癖","inner_conflict":"内在冲突","secret":"秘密","arc_notes":"人物弧光与成长线"}`
},
{ role: 'user', content: projectBrief(p) }
]
}
export function worldMessages(p: ProjectLike): ChatMessage[] {
return [
{
role: 'system',
content: `你是一位资深小说世界观架构师。请为这部作品设计世界观设定,共 6-12 条。
category 只能取:地理、势力、力量体系、物品、规则、历史、其他。
要求:设定具体可用、能直接支撑剧情写作;条目之间自洽;覆盖故事展开所必需的核心设定${JSON_ONLY}
数组元素格式:
{"category":"地理","title":"条目名","content":"具体设定描述"}`
},
{ role: 'user', content: projectBrief(p) }
]
}
export interface CharacterBrief {
name: string
role: string
goals: string
personality: string
}
export interface WorldBrief {
category: string
title: string
content: string
}
export function volumesMessages(p: ProjectLike, characters: CharacterBrief[], world: WorldBrief[]): ChatMessage[] {
const charText = characters.map((c) => `- ${c.name}(${c.role}):${c.goals || c.personality || ''}`).join('\n')
const worldText = world.map((w) => `- [${w.category}] ${w.title}:${w.content.slice(0, 120)}`).join('\n')
return [
{
role: 'system',
content: `你是一位资深小说主笔。请为这部作品设计分卷大纲。
要求:
- 共 1-5 卷(酌情决定),各卷 chapter_count 之和必须恰好等于 ${p.target_chapter_count}
- 整体节奏遵循「开局立意 → 发展升级 → 中段转折 → 高潮决战 → 结局收束」
- 每卷 summary 说明核心冲突、关键事件与该卷结尾的钩子(150-300 字)
- 充分利用下方人物关系与世界观设定
- 只输出一套最终分卷方案:每卷只出现一次,严禁输出备选方案、多个版本或重复条目${JSON_ONLY}
数组元素格式:
{"title":"卷名","summary":"本卷大纲","chapter_count":10}`
},
{
role: 'user',
content: `${projectBrief(p)}\n\n【人物表】\n${charText || '(暂无)'}\n\n【世界观设定】\n${worldText || '(暂无)'}`
}
]
}
export function beatsMessages(
p: ProjectLike,
volume: { title: string; summary: string; chapter_count: number },
characters: CharacterBrief[],
world: WorldBrief[],
prevRecap: string
): ChatMessage[] {
const charText = characters.map((c) => `- ${c.name}(${c.role}):${c.personality};目标:${c.goals}`).join('\n')
const worldText = world.map((w) => `- [${w.category}] ${w.title}:${w.content.slice(0, 150)}`).join('\n')
return [
{
role: 'system',
content: `你是一位资深小说主笔。请为《${volume.title}》卷设计逐章细纲,共 ${volume.chapter_count} 章。
每章要求:
- title 形如「第N章 章名」(N 从给定起始编号连续编号)
- events 用 3-6 句话描述本章关键事件,必须包含冲突或转折,结尾留钩子
- involved_characters 列出本章出场人物姓名(必须从提供的人物表中选取)
- pov 填本章视角人物姓名(从 involved_characters 中选一个,跟随谁的眼睛看故事);用户指定了全书叙事视角时必须遵守
- emotional_arc 填本章主角情绪曲线,形如「警惕→动摇→决意」(好的章节情绪必须有变化)
- purpose 填本章在全局中的功能,只能取:开局、铺垫、升温、转折、高潮、回落、收束、过渡
- tension 填本章张力等级 1-5 整数(全书应呈波浪上升,避免连续多章同分)
- foreshadow_plan 描述本章需要埋下或回收的伏笔,可为空字符串
章节之间承接递进,符合本卷大纲走向;张力和情绪曲线共同构成本卷的节奏
- 只输出一套最终细纲:每章只出现一次,严禁输出备选方案、多个版本或重复条目${JSON_ONLY}
数组元素格式:
{"title":"第N章 章名","events":"...","involved_characters":["人名"],"pov":"视角人物","emotional_arc":"警惕→动摇→决意","purpose":"升温","tension":3,"foreshadow_plan":"..."}`
},
{
role: 'user',
content: [
projectBrief(p),
`【人物表】\n${charText || '(暂无)'}`,
`【世界观设定】\n${worldText || '(暂无)'}`,
prevRecap ? `【前情概要(此前各卷已写内容)】\n${prevRecap}` : '',
`【本卷大纲】${volume.title}:${volume.summary}`
]
.filter(Boolean)
.join('\n\n')
}
]
}
export interface ProseContext {
project: ProjectLike
chapterNo: number
chapterTitle: string
volumeTitle?: string
beat?: {
events: string
foreshadow_plan: string
involved: string[]
pov: string
emotional_arc: string
purpose: string
tension: number
}
characterCards: string[]
worldEntries: string[]
prevSummaries: string[]
prevTail?: string
openForeshadows: string[]
}
export function proseMessages(ctx: ProseContext): ChatMessage[] {
const styleLine = ctx.project.style_notes ? `\n9. 文风要求:${ctx.project.style_notes}` : ''
const sampleLine = ctx.project.style_sample
? `\n【文风示例】(模仿其笔法与节奏,严禁抄写其内容):\n${ctx.project.style_sample.slice(0, 800)}`
: ''
return [
{
role: 'system',
content: `你是一位中文小说写作大师,正在按细纲为长篇小说撰写正文。写作要求:
1. 直接输出正文:第一段就是小说内容,不要输出章节标题、不要任何解释总结、不要出现"本章完"之类字样
2. 严格依据【本章细纲】推进剧情,细纲中的关键事件必须全部写到,不得擅自偏离
3. 人物言行必须符合人物卡的性格与语言习惯(speech_pattern);不同人物开口要有可区分的声口
4. 视角纪律:只写视角人物的所见所闻与内心,其他人物的内心只能通过表情、动作、语气等外部细节侧写,严禁视角漂移
5. 展示而非陈述(show, don't tell):情绪用动作、生理反应、环境互动来呈现,少用"他很愤怒"式直陈
6. 对话要有潜台词:人物不会直说真实意图,让立场与情绪藏在措辞、回避和答非所问里
7. 以场景与对话推动叙事,多用感官细节与人物动作,避免大段静态说明与信息倾泻(背景信息拆碎融进场景)
8. 篇幅约 ${ctx.project.target_chapter_words} 字,自然分段,段落之间空一行;结尾按细纲留钩子${styleLine}${sampleLine}`
},
{
role: 'user',
content: [
`【作品】《${ctx.project.name}》 题材:${ctx.project.genre || '未指定'}`,
ctx.project.narrative_pov ? `【全书叙事视角】${ctx.project.narrative_pov}` : '',
ctx.project.taboos ? `【禁用内容(严禁出现)】${ctx.project.taboos}` : '',
ctx.project.premise ? `【故事创意】${ctx.project.premise.slice(0, 600)}` : '',
ctx.worldEntries.length ? `【相关世界观设定】\n${ctx.worldEntries.join('\n')}` : '',
ctx.characterCards.length ? `【人物卡】\n${ctx.characterCards.join('\n\n')}` : '',
ctx.prevSummaries.length ? `【前情提要】\n${ctx.prevSummaries.join('\n')}` : '',
ctx.openForeshadows.length ? `【尚未回收的伏笔】\n${ctx.openForeshadows.map((f, i) => `${i + 1}. ${f}`).join('\n')}` : '',
ctx.prevTail ? `【上一章结尾】\n…${ctx.prevTail}` : '',
`【本章细纲】${ctx.volumeTitle ? `所属卷:${ctx.volumeTitle};` : ''}第${ctx.chapterNo}章《${ctx.chapterTitle}》`,
ctx.beat?.events ? `本章事件:${ctx.beat.events}` : '(无细纲,请依据前情自然推进)',
ctx.beat?.involved.length ? `本章涉及人物:${ctx.beat.involved.join('、')}` : '',
ctx.beat?.pov ? `本章视角人物:${ctx.beat.pov}` : '',
ctx.beat?.emotional_arc ? `本章情绪曲线(起点→终点,必须有变化):${ctx.beat.emotional_arc}` : '',
ctx.beat?.purpose ? `本章功能定位:${ctx.beat.purpose}` : '',
ctx.beat?.tension ? `本章张力等级:${ctx.beat.tension}/5(按等级控制冲突强度与节奏快慢)` : '',
ctx.beat?.foreshadow_plan ? `伏笔安排:${ctx.beat.foreshadow_plan}` : '',
'现在请撰写本章正文。'
]
.filter(Boolean)
.join('\n\n')
}
]
}
export interface RewriteParams {
mode: 'rewrite' | 'expand' | 'shrink' | 'custom'
instruction?: string
before: string
selected: string
after: string
}
export function rewriteMessages(p: RewriteParams): ChatMessage[] {
const modeText = {
rewrite: '重写该段:保持情节信息不变,提升文笔、画面感与人物声口',
expand: '扩写该段:补充动作、细节与心理描写,篇幅约为原来的 1.5-2 倍',
shrink: '压缩该段:保留关键信息,篇幅约为原来的一半',
custom: `按用户指令改写该段:${p.instruction || '(未给出指令)'}`
}[p.mode]
return [
{
role: 'system',
content: `你是中文小说润色师。任务:${modeText}。
要求:与前后文自然衔接;保持叙事视角与人称一致;只输出改写后的段落文本本身,不要解释、不要用引号包裹。`
},
{
role: 'user',
content: `${p.before ? `【前文】\n…${p.before}\n\n` : ''}【选中段落】\n${p.selected}\n\n${p.after ? `【后文】\n${p.after}…\n\n` : ''}请输出改写后的段落。`
}
]
}
export interface CharacterBackfillInput {
name: string
role: string
aliases: string
personality: string
background: string
goals: string
}
export const characterBackfillSchema = z.object({
name: z.string().min(1),
speech_pattern: z.string().default(''),
inner_conflict: z.string().default(''),
secret: z.string().default('')
})
export const characterBackfillArraySchema = z.array(characterBackfillSchema).min(1)
/** 为已有人物卡补写语言习惯 / 内在冲突 / 秘密三项专业字段 */
export function characterBackfillMessages(chars: CharacterBackfillInput[], p: ProjectLike): ChatMessage[] {
const list = chars
.map(
(c) =>
`- ${c.name}(${c.role}${c.aliases ? `,别称${c.aliases}` : ''}):性格 ${c.personality.slice(0, 120) || '(未填)'};背景 ${c.background.slice(0, 120) || '(未填)'};目标 ${c.goals.slice(0, 100) || '(未填)'}`
)
.join('\n')
return [
{
role: 'system',
content: `你是资深小说编辑。请依据给出的人物卡要点,为每个人物补写三项专业字段:
- speech_pattern 语言习惯与口癖:口头禅、句式偏好、说话节奏与音量、面对冲突时的说话方式;不同人物的声口必须彼此可区分
- inner_conflict 内在冲突:欲望与恐惧、理智与情感的拉扯,一句话点到要害
- secret 秘密:该人物知道但不为人知、未来可能影响剧情的事;没有秘密可填空字符串,但至少一半人物要有实质秘密
只输出 JSON 数组:每个人物一条,name 必须与输入完全一致,不得增删或改写人物${JSON_ONLY}
数组元素格式:
{"name":"姓名","speech_pattern":"...","inner_conflict":"...","secret":"..."}`
},
{ role: 'user', content: `【作品】《${p.name}》(${p.genre || '未指定题材'})\n【人物】\n${list}` }
]
}
export interface PostprocessInput {
chapterTitle: string
chapterNo: number
content: string
foreshadows: { id: number; description: string }[]
characterNames: string[]
}
export function postprocessMessages(input: PostprocessInput): ChatMessage[] {
return [
{
role: 'system',
content: `你是小说连载维护助手。阅读给出的章节正文,输出结构化 JSON 用于维护"故事圣经"。
- summary:本章剧情摘要,120-200 字,第三人称
- new_world_facts:本章新出现且值得长期记住的设定(新的地点、规则、物品、组织等),没有则空数组
- character_updates:本章中人物的状态/关系/能力/立场发生的变化,name 必须是已有人物名
- new_foreshadows:本章埋下的新伏笔(一句话一条)
- paid_foreshadow_ids:从提供的未回收伏笔列表中,选出本章已经回收/兑现的伏笔 id
- timeline_events:本章发生的关键事件(按故事内时间排序),story_time 填故事内时间(可为空字符串)${JSON_ONLY}
格式:
{"summary":"...","new_world_facts":[{"category":"地理|势力|力量体系|物品|规则|历史|其他","title":"...","content":"..."}],"character_updates":[{"name":"...","update":"..."}],"new_foreshadows":["..."],"paid_foreshadow_ids":[1,2],"timeline_events":[{"story_time":"...","title":"...","description":"..."}]}`
},
{
role: 'user',
content: [
`【章节】第${input.chapterNo}章《${input.chapterTitle}》`,
`【已有人物】${input.characterNames.join('、') || '(无)'}`,
`【未回收伏笔】${input.foreshadows.length ? JSON.stringify(input.foreshadows) : '(无)'}`,
`【正文】\n${input.content}`
].join('\n')
}
]
}
+108
View File
@@ -0,0 +1,108 @@
import { db } from '../db/connection.js'
import type { BeatParsed, ChapterRow, CharacterRow, ForeshadowRow, ProjectRow, TimelineEventRow, VolumeRow, WorldEntryRow } from '../db/types.js'
import { parseInvolved } from '../db/types.js'
import type { ProseContext } from '../llm/prompts.js'
import type { ProjectLike } from '../llm/prompts.js'
export interface Bundle {
project: ProjectRow
characters: CharacterRow[]
world: WorldEntryRow[]
volumes: VolumeRow[]
beats: BeatParsed[]
chapters: ChapterRow[]
foreshadows: ForeshadowRow[]
timeline: TimelineEventRow[]
}
export function loadBundle(projectId: number): Bundle | null {
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId) as ProjectRow | undefined
if (!project) return null
// involved_characters 以 JSON 字符串存储,出站统一解析为数组
const beats = (db.prepare('SELECT * FROM beats WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as BeatParsed[]).map(
(b) => ({ ...b, involved_characters: parseInvolved(b.involved_characters as unknown as string) })
)
return {
project,
characters: db.prepare('SELECT * FROM characters WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as CharacterRow[],
world: db.prepare('SELECT * FROM world_entries WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as WorldEntryRow[],
volumes: db.prepare('SELECT * FROM volumes WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as VolumeRow[],
beats,
chapters: db.prepare('SELECT * FROM chapters WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as ChapterRow[],
foreshadows: db.prepare('SELECT * FROM foreshadows WHERE project_id = ? ORDER BY id').all(projectId) as unknown as ForeshadowRow[],
timeline: db.prepare('SELECT * FROM timeline_events WHERE project_id = ? ORDER BY sort, id').all(projectId) as unknown as TimelineEventRow[]
}
}
export function toProjectLike(p: ProjectRow): ProjectLike {
return {
name: p.name,
genre: p.genre,
premise: p.premise,
style_notes: p.style_notes,
style_sample: p.style_sample,
narrative_pov: p.narrative_pov,
taboos: p.taboos,
target_chapter_words: p.target_chapter_words,
target_chapter_count: p.target_chapter_count
}
}
/** 为指定章节组装正文生成上下文 */
export function buildProseContext(bundle: Bundle, chapter: ChapterRow): ProseContext {
const chapterNo = bundle.chapters.findIndex((c) => c.id === chapter.id) + 1
const volume = bundle.volumes.find((v) => v.id === chapter.volume_id)
const beat = bundle.beats.find((b) => b.id === chapter.beat_id)
// 人物卡:细纲指定了涉及人物则取这些,否则取全部(上限 12 张)
const involvedIds = beat?.involved_characters ?? []
let chars = involvedIds.length
? involvedIds.map((id) => bundle.characters.find((c) => c.id === id)).filter((c): c is CharacterRow => !!c)
: bundle.characters.slice(0, 12)
if (chars.length === 0) chars = bundle.characters.slice(0, 12)
const characterCards = chars.map((c) =>
`【${c.name}】${c.aliases ? `(别称:${c.aliases})` : ''}(${c.role})\n外貌:${c.appearance}\n性格:${c.personality}\n背景:${c.background}\n目标:${c.goals}${c.speech_pattern ? `\n语言习惯:${c.speech_pattern}` : ''}${c.inner_conflict ? `\n内在冲突:${c.inner_conflict}` : ''}${c.secret ? `\n秘密(绝不可被其他人物直接知晓,除非剧情已揭示):${c.secret}` : ''}${c.arc_notes ? `\n近况与弧光:${c.arc_notes.slice(-400)}` : ''}`
)
// 世界观:数量少则全量注入,多则注入与细纲文本相关的条目
const beatText = beat ? `${beat.title} ${beat.events} ${beat.foreshadow_plan}` : chapter.title
const worldEntries = (
bundle.world.length <= 15
? bundle.world
: bundle.world.filter((w) => beatText.includes(w.title) || w.category === '规则' || w.category === '力量体系')
)
.slice(0, 15)
.map((w) => `[${w.category}] ${w.title}:${w.content}`)
// 前情:所有已写章节的摘要 + 上一章结尾 500 字
const idx = bundle.chapters.findIndex((c) => c.id === chapter.id)
const prevChapters = bundle.chapters.slice(0, Math.max(idx, 0)).filter((c) => c.content.trim().length > 0)
const prevSummaries = prevChapters.map((c, i) => `第${i + 1}章《${c.title}》:${c.summary || c.content.slice(0, 120) + '…'}`)
const prev = idx > 0 ? bundle.chapters[idx - 1] : undefined
const prevTail = prev && prev.content.trim() ? prev.content.replace(/\s+/g, ' ').slice(-500) : undefined
const openForeshadows = bundle.foreshadows.filter((f) => f.status === 'planted').map((f) => f.description)
return {
project: toProjectLike(bundle.project),
chapterNo: chapterNo || 1,
chapterTitle: chapter.title,
volumeTitle: volume?.title,
beat: beat
? {
events: beat.events,
foreshadow_plan: beat.foreshadow_plan,
involved: beat.involved_characters.map((id) => bundle.characters.find((c) => c.id === id)?.name ?? '').filter(Boolean),
pov: beat.pov,
emotional_arc: beat.emotional_arc,
purpose: beat.purpose,
tension: beat.tension
}
: undefined,
characterCards,
worldEntries,
prevSummaries,
prevTail,
openForeshadows
}
}
+117
View File
@@ -0,0 +1,117 @@
import { db, now } from '../db/connection.js'
import type { ChapterRow, CharacterRow, ForeshadowRow } from '../db/types.js'
import { getLlmConfig } from '../db/seed.js'
import { chatStructured } from '../llm/client.js'
import { postprocessMessages, postprocessSchema } from '../llm/prompts.js'
import { parseStructured } from '../util/json.js'
import { loadBundle } from './context.js'
import { countWords } from '../util/words.js'
export interface PostprocessResult {
summary: string
new_world_facts: number
character_updates: number
new_foreshadows: number
paid_foreshadows: number
timeline_events: number
}
/** 章节正文生成完成后:提取摘要/新设定/人物变化/伏笔/时间线并写回故事圣经 */
export async function runPostprocess(projectId: number, chapterId: number): Promise<PostprocessResult> {
const bundle = loadBundle(projectId)
if (!bundle) throw new Error('项目不存在')
const chapter = bundle.chapters.find((c) => c.id === chapterId) as ChapterRow | undefined
if (!chapter) throw new Error('章节不存在')
const content = chapter.content.trim()
if (!content) throw new Error('章节内容为空,无法回写')
const chapterNo = bundle.chapters.findIndex((c) => c.id === chapterId) + 1
const open = bundle.foreshadows.filter((f: ForeshadowRow) => f.status === 'planted')
const cfg = getLlmConfig()
const { data, usage } = await chatStructured(
cfg,
postprocessMessages({
chapterTitle: chapter.title,
chapterNo: chapterNo || 1,
content,
foreshadows: open.map((f) => ({ id: f.id, description: f.description.slice(0, 80) })),
characterNames: bundle.characters.map((c: CharacterRow) => c.name)
}),
(text) => parseStructured(text, postprocessSchema),
{ temperature: 0.3 }
)
db.prepare(
'INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)'
).run(projectId, 'postprocess', cfg.model, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, now())
db.exec('BEGIN')
try {
// 1. 摘要与字数
db.prepare('UPDATE chapters SET summary = ?, word_count = ?, updated_at = ? WHERE id = ?').run(
data.summary,
countWords(content),
now(),
chapterId
)
// 2. 新世界观条目
for (const w of data.new_world_facts) {
db.prepare('INSERT INTO world_entries (project_id, category, title, content, sort, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
w.category || '其他',
w.title,
w.content,
999,
now()
)
}
// 3. 人物近况追加到 arc_notes
for (const u of data.character_updates) {
const char = bundle.characters.find((c) => c.name === u.name || (c.aliases && c.aliases.includes(u.name)))
if (!char || !u.update) continue
const appended = `${char.arc_notes ? char.arc_notes + '\n' : ''}[第${chapterNo}章] ${u.update}`
db.prepare('UPDATE characters SET arc_notes = ?, updated_at = ? WHERE id = ?').run(appended.slice(-2000), now(), char.id)
}
// 4. 新伏笔
for (const desc of data.new_foreshadows) {
if (!desc.trim()) continue
db.prepare(
'INSERT INTO foreshadows (project_id, description, planted_chapter_id, status, notes, updated_at) VALUES (?,?,?,?,?,?)'
).run(projectId, desc.trim(), chapterId, 'planted', '', now())
}
// 5. 回收伏笔
for (const id of data.paid_foreshadow_ids) {
const f = open.find((x) => x.id === id)
if (!f) continue
db.prepare("UPDATE foreshadows SET status = 'paid', paid_chapter_id = ?, updated_at = ? WHERE id = ?").run(chapterId, now(), id)
}
// 6. 时间线
let sortBase = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM timeline_events WHERE project_id = ?').get(projectId) as { m: number }).m
for (const t of data.timeline_events) {
if (!t.title.trim()) continue
sortBase += 1
db.prepare('INSERT INTO timeline_events (project_id, chapter_id, story_time, title, description, sort) VALUES (?,?,?,?,?,?)').run(
projectId,
chapterId,
t.story_time,
t.title,
t.description,
sortBase
)
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
return {
summary: data.summary,
new_world_facts: data.new_world_facts.length,
character_updates: data.character_updates.length,
new_foreshadows: data.new_foreshadows.length,
paid_foreshadows: data.paid_foreshadow_ids.length,
timeline_events: data.timeline_events.length
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Router } from 'express'
import { z } from 'zod'
import type { SQLInputValue } from 'node:sqlite'
import { db, now } from '../db/connection.js'
/** 人物 / 世界观 / 伏笔 / 时间线 的 CRUD */
interface TableSpec {
path: string
table: string
orderBy: string
schema: z.ZodObject<z.ZodRawShape>
touchUpdatedAt: boolean
}
function crudRouter(spec: TableSpec): Router {
const r = Router()
const { table, schema } = spec
r.get(`/projects/:projectId/${spec.path}`, (req, res) => {
res.json(db.prepare(`SELECT * FROM ${table} WHERE project_id = ? ORDER BY ${spec.orderBy}`).all(Number(req.params.projectId)))
})
r.post(`/projects/:projectId/${spec.path}`, (req, res) => {
const projectId = Number(req.params.projectId)
if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId)) return res.status(404).json({ error: '项目不存在' })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data as Record<string, unknown>
// zod 已为所有字段补默认值,全部入库
const cols = ['project_id', ...Object.keys(schema.shape)]
const values: SQLInputValue[] = [projectId, ...cols.slice(1).map((c) => d[c] as SQLInputValue)]
if (spec.touchUpdatedAt) {
cols.push('updated_at')
values.push(now())
}
const info = db
.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`)
.run(...values)
res.json(db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(info.lastInsertRowid))
})
// 注意:按前端调用的 path(如 world / timeline)注册,而不是数据库表名(world_entries / timeline_events)
r.patch(`/${spec.path}/:id`, (req, res) => {
const parsed = schema.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined) as [string, SQLInputValue][]
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
if (spec.touchUpdatedAt) entries.push(['updated_at', now()])
const setClause = entries.map(([c]) => `${c} = ?`).join(', ')
db.prepare(`UPDATE ${table} SET ${setClause} WHERE id = ?`).run(...entries.map(([, v]) => v), Number(req.params.id))
res.json(db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(Number(req.params.id)))
})
r.delete(`/${spec.path}/:id`, (req, res) => {
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(Number(req.params.id))
res.json({ ok: true })
})
return r
}
export const assetsRouter = Router()
assetsRouter.use(
crudRouter({
path: 'characters',
table: 'characters',
orderBy: 'sort, id',
touchUpdatedAt: true,
schema: z.object({
name: z.string().min(1).max(60),
aliases: z.string().max(500).default(''),
role: z.string().max(30).default('配角'),
appearance: z.string().max(6000).default(''),
personality: z.string().max(6000).default(''),
background: z.string().max(12000).default(''),
goals: z.string().max(6000).default(''),
speech_pattern: z.string().max(6000).default(''),
inner_conflict: z.string().max(6000).default(''),
secret: z.string().max(6000).default(''),
arc_notes: z.string().max(12000).default(''),
sort: z.number().int().default(0)
})
})
)
assetsRouter.use(
crudRouter({
path: 'world',
table: 'world_entries',
orderBy: 'sort, id',
touchUpdatedAt: true,
schema: z.object({
category: z.string().max(30).default('其他'),
title: z.string().min(1).max(100),
content: z.string().max(12000).default(''),
sort: z.number().int().default(0)
})
})
)
assetsRouter.use(
crudRouter({
path: 'foreshadows',
table: 'foreshadows',
orderBy: 'id',
touchUpdatedAt: true,
schema: z.object({
description: z.string().min(1).max(4000),
planted_chapter_id: z.number().int().nullable().default(null),
paid_chapter_id: z.number().int().nullable().default(null),
status: z.enum(['planted', 'paid']).default('planted'),
notes: z.string().max(8000).default('')
})
})
)
assetsRouter.use(
crudRouter({
path: 'timeline',
table: 'timeline_events',
orderBy: 'sort, id',
touchUpdatedAt: false,
schema: z.object({
chapter_id: z.number().int().nullable().default(null),
story_time: z.string().max(100).default(''),
title: z.string().min(1).max(200),
description: z.string().max(8000).default(''),
sort: z.number().int().default(0)
})
})
)
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { countWords } from '../util/words.js'
import { runPostprocess } from '../pipeline/postprocess.js'
export const chaptersRouter = Router()
const chapterInput = z.object({
beat_id: z.number().int().nullable().default(null),
volume_id: z.number().int().nullable().default(null),
title: z.string().min(1).max(120),
sort: z.number().int().default(0)
})
chaptersRouter.post('/projects/:projectId/chapters', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = chapterInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const info = db
.prepare('INSERT INTO chapters (project_id, beat_id, volume_id, sort, title, updated_at) VALUES (?,?,?,?,?,?)')
.run(projectId, d.beat_id, d.volume_id, d.sort, d.title, now())
res.json(db.prepare('SELECT * FROM chapters WHERE id = ?').get(info.lastInsertRowid))
})
chaptersRouter.get('/chapters/:id', (req, res) => {
const row = db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id))
if (!row) return res.status(404).json({ error: '章节不存在' })
res.json(row)
})
chaptersRouter.patch('/chapters/:id', (req, res) => {
const parsed = z
.object({
title: z.string().min(1).max(120).optional(),
content: z.string().max(500000).optional(),
status: z.enum(['empty', 'generated', 'edited']).optional(),
sort: z.number().int().optional(),
volume_id: z.number().int().nullable().optional(),
summary: z.string().max(4000).optional()
})
.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const cur = db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id)) as
| { content: string; word_count: number }
| undefined
if (!cur) return res.status(404).json({ error: '章节不存在' })
const wordCount = d.content !== undefined ? countWords(d.content) : cur.word_count
db.prepare('UPDATE chapters SET title = COALESCE(?, title), content = COALESCE(?, content), status = COALESCE(?, status), sort = COALESCE(?, sort), volume_id = COALESCE(?, volume_id), summary = COALESCE(?, summary), word_count = ?, updated_at = ? WHERE id = ?').run(
d.title ?? null,
d.content ?? null,
d.status ?? null,
d.sort ?? null,
d.volume_id ?? null,
d.summary ?? null,
wordCount,
now(),
Number(req.params.id)
)
res.json(db.prepare('SELECT * FROM chapters WHERE id = ?').get(Number(req.params.id)))
})
chaptersRouter.delete('/chapters/:id', (req, res) => {
db.prepare('DELETE FROM chapters WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
// 正文定稿后触发:AI 提取摘要/设定/人物变化/伏笔/时间线并写回故事圣经
chaptersRouter.post('/chapters/:id/postprocess', async (req, res) => {
const id = Number(req.params.id)
const row = db.prepare('SELECT project_id FROM chapters WHERE id = ?').get(id) as { project_id: number } | undefined
if (!row) return res.status(404).json({ error: '章节不存在' })
try {
const result = await runPostprocess(row.project_id, id)
res.json(result)
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) })
}
})
+52
View File
@@ -0,0 +1,52 @@
import { Router } from 'express'
import { loadBundle } from '../pipeline/context.js'
import { buildEpub } from '../util/epub.js'
import type { EpubChapter } from '../util/epub.js'
export const exportRouter = Router()
function collect(projectId: number) {
const bundle = loadBundle(projectId)
if (!bundle) return null
const written = bundle.chapters.filter((c) => c.content.trim().length > 0)
return { bundle, written }
}
const filenameHeader = (name: string, ext: string) =>
`attachment; filename*=UTF-8''${encodeURIComponent(`${name}.${ext}`)}`
exportRouter.get('/export/:projectId/txt', (req, res) => {
const data = collect(Number(req.params.projectId))
if (!data) return res.status(404).json({ error: '项目不存在' })
const { bundle, written } = data
const parts: string[] = [`《${bundle.project.name}》`, bundle.project.genre ? `题材:${bundle.project.genre}` : '', '']
for (const c of written) {
const vol = bundle.volumes.find((v) => v.id === c.volume_id)
parts.push(`\n\n══════ 第${bundle.chapters.findIndex((x) => x.id === c.id) + 1}章 ${c.title} ══════\n`)
if (vol) parts.push(`(${vol.title})\n\n`)
parts.push(c.content.trim())
}
res.set('Content-Type', 'text/plain; charset=utf-8')
res.set('Content-Disposition', filenameHeader(bundle.project.name, 'txt'))
res.send('\ufeff' + parts.join('\n'))
})
exportRouter.get('/export/:projectId/epub', async (req, res) => {
const data = collect(Number(req.params.projectId))
if (!data) return res.status(404).json({ error: '项目不存在' })
const { bundle, written } = data
if (written.length === 0) return res.status(400).json({ error: '暂无已写章节,无法导出' })
const chapters: EpubChapter[] = written.map((c) => ({
volumeTitle: bundle.volumes.find((v) => v.id === c.volume_id)?.title,
title: c.title,
paragraphs: c.content.split(/\n+/).map((s) => s.trim()).filter(Boolean)
}))
try {
const buf = await buildEpub(bundle.project.name, chapters)
res.set('Content-Type', 'application/epub+zip')
res.set('Content-Disposition', filenameHeader(bundle.project.name, 'epub'))
res.send(buf)
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) })
}
})
+450
View File
@@ -0,0 +1,450 @@
import { Router } from 'express'
import type { Request, Response } from 'express'
import type { SQLInputValue } from 'node:sqlite'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { getLlmConfig } from '../db/seed.js'
import type { BeatParsed, BeatRow, ChapterRow, VolumeRow } from '../db/types.js'
import { parseInvolved } from '../db/types.js'
import { chatStream, chatStructured, type ChatMessage } from '../llm/client.js'
import {
beatArraySchema,
beatsMessages,
characterBackfillArraySchema,
characterBackfillMessages,
characterCardArraySchema,
charactersMessages,
proseMessages,
rewriteMessages,
volumeArraySchema,
volumesMessages,
worldEntryArraySchema,
worldMessages
} from '../llm/prompts.js'
import { buildProseContext, loadBundle, toProjectLike } from '../pipeline/context.js'
import { parseStructured } from '../util/json.js'
import { countWords } from '../util/words.js'
import { sseInit, sseSend } from '../util/sse.js'
import { mapNamesToIds } from './outline.js'
export const generateRouter = Router()
/** 通用流式执行:把模型增量透传为 SSE,结束时回调并记录日志 */
async function streamStage(req: Request, res: Response, stage: string, projectId: number, messages: Parameters<typeof chatStream>[1]) {
sseInit(res)
const cfg = getLlmConfig()
const abort = new AbortController()
req.on('close', () => abort.abort())
let usage: { prompt_tokens?: number; completion_tokens?: number } | undefined
try {
const result = await chatStream(cfg, messages, {
signal: abort.signal,
onDelta: (t) => sseSend(res, 'delta', { text: t })
})
usage = result.usage
sseSend(res, 'done', { content: result.content, usage: result.usage })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
projectId,
stage,
cfg.model,
result.usage?.prompt_tokens ?? 0,
result.usage?.completion_tokens ?? 0,
now()
)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (!abort.signal.aborted) {
sseSend(res, 'error', { message: msg })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(projectId, stage, cfg.model, now())
}
} finally {
res.end()
}
}
/** 结构化生成阶段:流式输出原始文本 + done 事件携带解析后的 JSON */
async function structuredStage<T>(
req: Request,
res: Response,
stage: string,
projectId: number,
messages: Parameters<typeof chatStructured>[1],
validate: (text: string) => T
) {
sseInit(res)
const cfg = getLlmConfig()
const abort = new AbortController()
req.on('close', () => abort.abort())
try {
const { data, usage } = await chatStructured(
cfg,
messages,
validate,
{ signal: abort.signal, onDelta: (t) => sseSend(res, 'delta', { text: t }) }
)
sseSend(res, 'done', { data, usage })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
projectId,
stage,
cfg.model,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
now()
)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (!abort.signal.aborted) {
sseSend(res, 'error', { message: msg })
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(projectId, stage, cfg.model, now())
}
} finally {
res.end()
}
}
const pidBody = z.object({ projectId: z.number().int(), requirement: z.string().max(3000).optional() })
/** 把用户对本步骤的额外要求追加到最后一条 user 消息(人工约束优先于默认策略) */
function withRequirement(messages: ChatMessage[], requirement?: string): ChatMessage[] {
const text = requirement?.trim()
if (!text) return messages
const msgs = [...messages]
const last = msgs[msgs.length - 1]
msgs[msgs.length - 1] = { ...last, content: `${last.content}\n\n【用户对本次生成的额外要求(必须遵守,与默认要求冲突时以用户要求为准)】\n${text}` }
return msgs
}
/** 提取标题中的序号(卷一/第1卷/第一部/第12章),模型输出多版本方案时据此去重 */
function titleOrdinal(title: string, kind: 'vol' | 'beat'): string | null {
const t = title.replace(/\s+/g, '')
if (kind === 'beat') return /^第([0-90-9一二三四五六七八九十百]+)[章回]/.exec(t)?.[1] ?? null
// 「第1卷 / 1卷 / 第一部」数字在前,「卷一 / 部二」数字在后,两种写法都支持
const num = '[0-90-9一二三四五六七八九十百]+'
const m = new RegExp(`^(?:第)?(${num})[卷部篇]`).exec(t) ?? new RegExp(`^[卷部篇](${num})`).exec(t)
return m?.[1] ?? null
}
function normalizeTitleKey(title: string): string {
return title.replace(/[\s::||·、,,。.\-—~~()()【】[\]]+/g, '')
}
/** 模型偶尔会输出多套方案(同一序号出现多次),只保留每个序号的第一条 */
function dedupeByTitle<T>(items: T[], titleOf: (t: T) => string, kind: 'vol' | 'beat'): T[] {
const seenOrd = new Set<string>()
const seenText = new Set<string>()
const out: T[] = []
for (const it of items) {
const ord = titleOrdinal(titleOf(it), kind)
if (ord) {
if (seenOrd.has(ord)) continue
seenOrd.add(ord)
} else {
const key = normalizeTitleKey(titleOf(it))
if (seenText.has(key)) continue
seenText.add(key)
}
out.push(it)
}
return out
}
// ---------- 人物卡 ----------
generateRouter.post('/generate/characters', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'characters', bundle.project.id, withRequirement(charactersMessages(toProjectLike(bundle.project)), parsed.data.requirement), (t) =>
parseStructured(t, characterCardArraySchema)
)
})
// ---------- 世界观 ----------
generateRouter.post('/generate/world', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'world', bundle.project.id, withRequirement(worldMessages(toProjectLike(bundle.project)), parsed.data.requirement), (t) =>
parseStructured(t, worldEntryArraySchema)
)
})
// ---------- 分卷大纲 ----------
generateRouter.post('/generate/volumes', (req, res) => {
const parsed = pidBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
void structuredStage(req, res, 'volumes', bundle.project.id, withRequirement(volumesMessages(toProjectLike(bundle.project), bundle.characters, bundle.world), parsed.data.requirement), (t) =>
dedupeByTitle(parseStructured(t, volumeArraySchema), (v) => v.title, 'vol')
)
})
// ---------- 分章细纲(按卷) ----------
generateRouter.post('/generate/beats', (req, res) => {
const parsed = z.object({ projectId: z.number().int(), volumeId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const volume = bundle.volumes.find((v: VolumeRow) => v.id === parsed.data.volumeId)
if (!volume) return res.status(404).json({ error: '卷不存在' })
// 起始编号 = 此前所有卷的细纲章数之和 + 1;前情概要 = 此前各卷细纲标题序列
const sortedVolumes = [...bundle.volumes].sort((a, b) => a.sort - b.sort)
const volumeIdx = sortedVolumes.findIndex((v) => v.id === volume.id)
const prevVolumes = sortedVolumes.slice(0, volumeIdx)
const startNo = prevVolumes.reduce((acc, v) => acc + bundle.beats.filter((b: BeatParsed) => b.volume_id === v.id).length, 0) + 1
const prevRecap = prevVolumes
.map((v) => {
const volBeats = bundle.beats.filter((b: BeatParsed) => b.volume_id === v.id).sort((a, b) => a.sort - b.sort)
return `《${v.title}》:${volBeats.map((b) => b.title.replace(/^第\d+章\s*/, '')).join('、')}`
})
.join('\n')
// 本卷目标章数:应用大纲时创建的空章节数;没有章节信息时默认 10
const existing = bundle.chapters.filter((c: ChapterRow) => c.volume_id === volume.id).length
const chapterCount = existing > 0 ? existing : 10
const messages = beatsMessages(
toProjectLike(bundle.project),
{ title: volume.title, summary: volume.summary, chapter_count: chapterCount },
bundle.characters,
bundle.world,
prevRecap
)
messages[1] = {
role: 'user',
content: `${messages[1].content}\n\n【编号要求】章节从第${startNo}章开始连续编号;共 ${chapterCount} 章,不可多不可少。`
}
void structuredStage(req, res, 'beats', bundle.project.id, withRequirement(messages, parsed.data.requirement), (t) =>
dedupeByTitle(parseStructured(t, beatArraySchema), (b) => b.title, 'beat')
)
})
// ---------- 正文(流式) ----------
generateRouter.post('/generate/prose', (req, res) => {
const parsed = z.object({ projectId: z.number().int(), chapterId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const chapter = bundle.chapters.find((c: ChapterRow) => c.id === parsed.data.chapterId)
if (!chapter) return res.status(404).json({ error: '章节不存在' })
const ctx = buildProseContext(bundle, chapter)
void streamStage(req, res, 'prose', bundle.project.id, withRequirement(proseMessages(ctx), parsed.data.requirement))
})
// ---------- 段落重写(流式) ----------
generateRouter.post('/generate/rewrite', (req, res) => {
const parsed = z
.object({
projectId: z.number().int(),
mode: z.enum(['rewrite', 'expand', 'shrink', 'custom']),
instruction: z.string().max(1000).optional(),
selected: z.string().min(1).max(20000),
before: z.string().max(1000).default(''),
after: z.string().max(1000).default('')
})
.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
void streamStage(req, res, `rewrite:${parsed.data.mode}`, parsed.data.projectId, rewriteMessages(parsed.data))
})
// ================= 采纳落库 =================
// ---------- 为旧人物卡补写专业字段(声口 / 内在冲突 / 秘密),只填空字段不覆盖已填 ----------
generateRouter.post('/generate/backfill/characters', async (req, res) => {
const parsed = z.object({ projectId: z.number().int(), requirement: z.string().max(3000).optional() }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '参数错误' })
const bundle = loadBundle(parsed.data.projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const targets = bundle.characters.filter((c) => !c.speech_pattern.trim() || !c.inner_conflict.trim() || !c.secret.trim())
if (targets.length === 0) return res.json({ updated: 0, message: '所有人物卡均已补全' })
const cfg = getLlmConfig()
const messages = withRequirement(
characterBackfillMessages(
targets.map((c) => ({ name: c.name, role: c.role, aliases: c.aliases, personality: c.personality, background: c.background, goals: c.goals })),
toProjectLike(bundle.project)
),
parsed.data.requirement
)
try {
const { data, usage } = await chatStructured(cfg, messages, (t) => parseStructured(t, characterBackfillArraySchema), {
signal: AbortSignal.timeout(180000)
})
let updated = 0
db.exec('BEGIN')
try {
for (const item of data) {
const hit = targets.find((c) => c.name === item.name || (c.aliases && c.aliases.split(/[、,,;;\s]+/).includes(item.name)))
if (!hit) continue
const sets: string[] = []
const vals: SQLInputValue[] = []
if (!hit.speech_pattern.trim() && item.speech_pattern.trim()) {
sets.push('speech_pattern=?')
vals.push(item.speech_pattern)
}
if (!hit.inner_conflict.trim() && item.inner_conflict.trim()) {
sets.push('inner_conflict=?')
vals.push(item.inner_conflict)
}
if (!hit.secret.trim() && item.secret.trim()) {
sets.push('secret=?')
vals.push(item.secret)
}
if (sets.length === 0) continue
vals.push(now(), hit.id)
db.prepare(`UPDATE characters SET ${sets.join(', ')}, updated_at=? WHERE id=?`).run(...vals)
updated++
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
db.prepare('INSERT INTO generation_logs (project_id, stage, model, prompt_tokens, completion_tokens, ok, created_at) VALUES (?,?,?,?,?,1,?)').run(
bundle.project.id,
'backfill:characters',
cfg.model,
usage?.prompt_tokens ?? 0,
usage?.completion_tokens ?? 0,
now()
)
res.json({ updated, filled: data.length })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.prepare('INSERT INTO generation_logs (project_id, stage, model, ok, created_at) VALUES (?,?,?,0,?)').run(bundle.project.id, 'backfill:characters', cfg.model, now())
res.status(500).json({ error: msg })
}
})
const applyCharacters = z.object({ projectId: z.number().int(), items: characterCardArraySchema })
generateRouter.post('/generate/apply/characters', (req, res) => {
const parsed = applyCharacters.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '人物数据格式错误' })
const { projectId, items } = parsed.data
const base = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM characters WHERE project_id = ?').get(projectId) as { m: number }).m
db.exec('BEGIN')
try {
items.forEach((c, i) => {
db.prepare(
'INSERT INTO characters (project_id, name, aliases, role, appearance, personality, background, goals, speech_pattern, inner_conflict, secret, arc_notes, sort, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
).run(
projectId,
c.name,
c.aliases,
c.role,
c.appearance,
c.personality,
c.background,
c.goals,
c.speech_pattern,
c.inner_conflict,
c.secret,
c.arc_notes,
base + i + 1,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyWorld = z.object({ projectId: z.number().int(), items: worldEntryArraySchema })
generateRouter.post('/generate/apply/world', (req, res) => {
const parsed = applyWorld.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '设定数据格式错误' })
const { projectId, items } = parsed.data
const base = (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM world_entries WHERE project_id = ?').get(projectId) as { m: number }).m
db.exec('BEGIN')
try {
items.forEach((w, i) => {
db.prepare('INSERT INTO world_entries (project_id, category, title, content, sort, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
w.category,
w.title,
w.content,
base + i + 1,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyVolumes = z.object({ projectId: z.number().int(), items: volumeArraySchema })
generateRouter.post('/generate/apply/volumes', (req, res) => {
const parsed = applyVolumes.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '大纲数据格式错误' })
const { projectId } = parsed.data
const items = dedupeByTitle(parsed.data.items, (v) => v.title, 'vol')
// 重建大纲:删除旧卷与细纲;无内容的空章节一并清除,已写正文的章节保留为「未分卷」
db.exec('BEGIN')
try {
db.prepare("DELETE FROM chapters WHERE project_id = ? AND status = 'empty'").run(projectId)
db.prepare('DELETE FROM beats WHERE project_id = ?').run(projectId)
db.prepare('DELETE FROM volumes WHERE project_id = ?').run(projectId)
items.forEach((v, i) => {
db.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)').run(projectId, v.title, v.summary, i + 1)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
const applyBeats = z.object({ projectId: z.number().int(), volumeId: z.number().int(), items: beatArraySchema })
generateRouter.post('/generate/apply/beats', (req, res) => {
const parsed = applyBeats.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: '细纲数据格式错误' })
const { projectId, volumeId } = parsed.data
const items = dedupeByTitle(parsed.data.items, (b) => b.title, 'beat')
const bundle = loadBundle(projectId)
if (!bundle) return res.status(404).json({ error: '项目不存在' })
const volume = bundle.volumes.find((v: VolumeRow) => v.id === volumeId)
if (!volume) return res.status(404).json({ error: '卷不存在' })
// 全书全局排序:此前各卷章数之和作为本卷起始序号
const sortedVolumes = [...bundle.volumes].sort((a, b) => a.sort - b.sort)
const beforeCount = sortedVolumes
.slice(0, sortedVolumes.findIndex((v) => v.id === volumeId))
.reduce((acc, v) => acc + bundle.chapters.filter((c: ChapterRow) => c.volume_id === v.id).length, 0)
db.exec('BEGIN')
try {
db.prepare('DELETE FROM beats WHERE volume_id = ?').run(volumeId)
// 清掉本卷空章节(内容章节保留,beat 关联自动断开)
db.prepare("DELETE FROM chapters WHERE volume_id = ? AND status = 'empty'").run(volumeId)
items.forEach((b, i) => {
const involvedIds = mapNamesToIds(b.involved_characters, bundle.characters)
const info = db
.prepare(
'INSERT INTO beats (project_id, volume_id, sort, title, events, involved_characters, foreshadow_plan, pov, emotional_arc, purpose, tension) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(projectId, volumeId, i + 1, b.title, b.events, JSON.stringify(involvedIds), b.foreshadow_plan, b.pov ?? '', b.emotional_arc ?? '', b.purpose ?? '', b.tension ?? 0)
db.prepare('INSERT INTO chapters (project_id, beat_id, volume_id, sort, title, updated_at) VALUES (?,?,?,?,?,?)').run(
projectId,
info.lastInsertRowid,
volumeId,
beforeCount + i + 1,
b.title,
now()
)
})
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
res.json({ inserted: items.length })
})
+119
View File
@@ -0,0 +1,119 @@
import { Router } from 'express'
import { z } from 'zod'
import { db } from '../db/connection.js'
import { parseInvolved } from '../db/types.js'
export const outlineRouter = Router()
// ---------- 分卷 ----------
const volumeInput = z.object({
title: z.string().min(1).max(100),
summary: z.string().max(4000).default(''),
sort: z.number().int().default(0)
})
outlineRouter.post('/projects/:projectId/volumes', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = volumeInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const info = db
.prepare('INSERT INTO volumes (project_id, title, summary, sort) VALUES (?,?,?,?)')
.run(projectId, parsed.data.title, parsed.data.summary, parsed.data.sort)
res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(info.lastInsertRowid))
})
outlineRouter.patch('/volumes/:id', (req, res) => {
const parsed = volumeInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined)
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
db.prepare(`UPDATE volumes SET ${entries.map(([c]) => `${c} = ?`).join(', ')} WHERE id = ?`).run(
...entries.map(([, v]) => v),
Number(req.params.id)
)
res.json(db.prepare('SELECT * FROM volumes WHERE id = ?').get(Number(req.params.id)))
})
// 删除卷:细纲与章节保留(volume_id 置空)
outlineRouter.delete('/volumes/:id', (req, res) => {
db.prepare('DELETE FROM volumes WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
// ---------- 分章细纲 ----------
const beatInput = z.object({
volume_id: z.number().int().nullable().default(null),
title: z.string().min(1).max(120),
events: z.string().max(12000).default(''),
involved_characters: z.array(z.number().int()).default([]),
foreshadow_plan: z.string().max(4000).default(''),
pov: z.string().max(60).default(''),
emotional_arc: z.string().max(200).default(''),
purpose: z.string().max(30).default(''),
tension: z.number().int().min(0).max(5).default(0)
})
outlineRouter.post('/projects/:projectId/beats', (req, res) => {
const projectId = Number(req.params.projectId)
const parsed = beatInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const maxSort = db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM beats WHERE project_id = ?').get(projectId) as { m: number }
const sort = d.volume_id
? (db.prepare('SELECT COALESCE(MAX(sort),0) AS m FROM beats WHERE volume_id = ?').get(d.volume_id) as { m: number }).m + 1
: maxSort.m + 1
const info = db
.prepare(
'INSERT INTO beats (project_id, volume_id, sort, title, events, involved_characters, foreshadow_plan, pov, emotional_arc, purpose, tension) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(projectId, d.volume_id, sort, d.title, d.events, JSON.stringify(d.involved_characters), d.foreshadow_plan, d.pov, d.emotional_arc, d.purpose, d.tension)
res.json(parseBeatRow(db.prepare('SELECT * FROM beats WHERE id = ?').get(info.lastInsertRowid)))
})
outlineRouter.patch('/beats/:id', (req, res) => {
const parsed = beatInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const entries = Object.entries(parsed.data).filter(([, v]) => v !== undefined) as [string, string | number | null][]
if (entries.length === 0) return res.status(400).json({ error: '无更新字段' })
// involved_characters 以 JSON 字符串入库
const mapped = entries.map(([k, v]) => (k === 'involved_characters' ? (['involved_characters', JSON.stringify(v)] as const) : ([k, v] as const)))
db.prepare(`UPDATE beats SET ${mapped.map(([c]) => `${c} = ?`).join(', ')} WHERE id = ?`).run(
...mapped.map(([, v]) => v),
Number(req.params.id)
)
res.json(parseBeatRow(db.prepare('SELECT * FROM beats WHERE id = ?').get(Number(req.params.id))))
})
/** 出站形态:involved_characters 解析为数组 */
function parseBeatRow(row: unknown) {
const r = row as { involved_characters: string } | undefined
if (!r) return row
try {
const arr = JSON.parse(r.involved_characters)
return { ...r, involved_characters: Array.isArray(arr) ? arr : [] }
} catch {
return { ...r, involved_characters: [] }
}
}
outlineRouter.delete('/beats/:id', (req, res) => {
db.prepare('DELETE FROM beats WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
/** 工具:把模型输出的涉及人物名映射为人物 id */
export function mapNamesToIds(names: string[], characters: { id: number; name: string; aliases: string }[]): number[] {
const ids: number[] = []
for (const raw of names) {
const name = String(raw).trim()
if (!name) continue
const hit =
characters.find((c) => c.name === name) ??
characters.find((c) => c.aliases && c.aliases.split(/[、,,;;\s]+/).includes(name)) ??
characters.find((c) => c.name.includes(name) || name.includes(c.name))
if (hit && !ids.includes(hit.id)) ids.push(hit.id)
}
return ids
}
export { parseInvolved }
+80
View File
@@ -0,0 +1,80 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { loadBundle } from '../pipeline/context.js'
export const projectsRouter = Router()
const projectInput = z.object({
name: z.string().min(1, '作品名不能为空').max(200),
genre: z.string().max(100).default(''),
premise: z.string().max(60000, '故事创意过长(上限 6 万字符)').default(''),
style_notes: z.string().max(20000).default(''),
style_sample: z.string().max(40000).default(''),
narrative_pov: z.string().max(100).default(''),
taboos: z.string().max(2000).default(''),
target_chapter_words: z.number().int().min(200).max(20000).default(3000),
target_chapter_count: z.number().int().min(1).max(2000).default(30)
})
projectsRouter.get('/projects', (_req, res) => {
const rows = db
.prepare(
`SELECT p.*,
COALESCE((SELECT SUM(word_count) FROM chapters c WHERE c.project_id = p.id), 0) AS total_words,
(SELECT COUNT(*) FROM chapters c WHERE c.project_id = p.id) AS chapter_count,
(SELECT COUNT(*) FROM chapters c WHERE c.project_id = p.id AND c.content != '') AS written_count
FROM projects p ORDER BY p.updated_at DESC`
)
.all()
res.json(rows)
})
projectsRouter.post('/projects', (req, res) => {
const parsed = projectInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
const t = now()
const r = db
.prepare(
'INSERT INTO projects (name, genre, premise, style_notes, style_sample, narrative_pov, taboos, target_chapter_words, target_chapter_count, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)
.run(d.name, d.genre, d.premise, d.style_notes, d.style_sample, d.narrative_pov, d.taboos, d.target_chapter_words, d.target_chapter_count, t, t)
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(r.lastInsertRowid))
})
projectsRouter.patch('/projects/:id', (req, res) => {
const id = Number(req.params.id)
const parsed = projectInput.partial().safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const cur = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Record<string, unknown> | undefined
if (!cur) return res.status(404).json({ error: '项目不存在' })
const d = { ...cur, ...parsed.data, updated_at: now() }
db.prepare(
'UPDATE projects SET name=?, genre=?, premise=?, style_notes=?, style_sample=?, narrative_pov=?, taboos=?, target_chapter_words=?, target_chapter_count=?, updated_at=? WHERE id=?'
).run(
d.name as string,
d.genre as string,
d.premise as string,
d.style_notes as string,
d.style_sample as string,
d.narrative_pov as string,
d.taboos as string,
d.target_chapter_words as number,
d.target_chapter_count as number,
d.updated_at as string,
id
)
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(id))
})
projectsRouter.delete('/projects/:id', (req, res) => {
db.prepare('DELETE FROM projects WHERE id = ?').run(Number(req.params.id))
res.json({ ok: true })
})
projectsRouter.get('/projects/:id/bundle', (req, res) => {
const bundle = loadBundle(Number(req.params.id))
if (!bundle) return res.status(404).json({ error: '项目不存在' })
res.json(bundle)
})
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express'
import { z } from 'zod'
import { db, now } from '../db/connection.js'
import { getLlmConfig } from '../db/seed.js'
import { testConnection } from '../llm/client.js'
export const settingsRouter = Router()
const settingsInput = z.object({
base_url: z.string().url().max(300),
api_key: z.string().max(300).default(''),
model: z.string().min(1).max(200),
temperature: z.number().min(0).max(2).default(0.8),
max_tokens: z.number().int().min(256).max(65536).default(12000)
})
settingsRouter.get('/settings', (_req, res) => {
const cfg = getLlmConfig()
res.json({ ...cfg, id: 1, updated_at: now() })
})
settingsRouter.put('/settings', (req, res) => {
const parsed = settingsInput.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
const d = parsed.data
db.prepare('UPDATE llm_config SET base_url=?, api_key=?, model=?, temperature=?, max_tokens=?, updated_at=? WHERE id=1').run(
d.base_url,
d.api_key,
d.model,
d.temperature,
d.max_tokens,
now()
)
res.json(getLlmConfig())
})
settingsRouter.post('/settings/test', async (req, res) => {
const parsed = z.object({ base_url: z.string().url(), api_key: z.string(), model: z.string().min(1) }).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message })
try {
const result = await testConnection(parsed.data)
res.json({ ok: true, ...result })
} catch (e) {
res.json({ ok: false, error: e instanceof Error ? e.message : String(e) })
}
})
+103
View File
@@ -0,0 +1,103 @@
import JSZip from 'jszip'
export interface EpubChapter {
volumeTitle?: string
title: string
paragraphs: string[]
}
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
const CSS = `body{font-family:serif;line-height:1.8;margin:1em}h1{font-size:1.4em;text-align:center}h2{font-size:1.2em;text-align:center}p{text-indent:2em;margin:0.5em 0}`
/** 手工构建 EPUB3(mimetype 首位 + 容器 + OPF + 导航 + 章节),零不确定依赖 */
export async function buildEpub(title: string, chapters: EpubChapter[]): Promise<Buffer> {
const zip = new JSZip()
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
zip.file(
'META-INF/container.xml',
`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
)
zip.file('OEBPS/style.css', CSS)
// 卷分组导航
const groups: { volume?: string; chapters: { idx: number; title: string }[] }[] = []
for (let i = 0; i < chapters.length; i++) {
const c = chapters[i]
const last = groups[groups.length - 1]
if (last && last.volume === c.volumeTitle) last.chapters.push({ idx: i, title: c.title })
else groups.push({ volume: c.volumeTitle, chapters: [{ idx: i, title: c.title }] })
}
const chapterFiles = chapters.map((_, i) => `chapter_${String(i + 1).padStart(4, '0')}.xhtml`)
chapters.forEach((c, i) => {
const volLine = c.volumeTitle ? `<p class="volume" style="text-align:center;color:#666">${esc(c.volumeTitle)}</p>` : ''
zip.file(
`OEBPS/${chapterFiles[i]}`,
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>${esc(c.title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body><h2>${esc(c.title)}</h2>${volLine}${c.paragraphs.map((p) => `<p>${esc(p)}</p>`).join('')}</body></html>`
)
})
const navLis = groups
.map((g) => {
const label = esc(g.volume || '正文')
const items = g.chapters
.map((ch) => `<li><a href="${chapterFiles[ch.idx]}">${esc(ch.title)}</a></li>`)
.join('')
return g.volume ? `<li><span>${label}</span><ol>${items}</ol></li>` : items
})
.join('')
zip.file(
'OEBPS/nav.xhtml',
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>目录</title></head>
<body><nav epub:type="toc" id="toc"><h1>目录</h1><ol>${navLis}</ol></nav></body></html>`
)
zip.file(
'OEBPS/title.xhtml',
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="zh-CN"><head><meta charset="utf-8"/><title>${esc(title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body style="text-align:center;padding-top:8em"><h1>${esc(title)}</h1></body></html>`
)
const manifest = [
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
'<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>',
'<item id="css" href="style.css" media-type="text/css"/>',
...chapterFiles.map((f, i) => `<item id="ch${i + 1}" href="${f}" media-type="application/xhtml+xml"/>`)
].join('\n ')
const spine = ['<itemref idref="title"/>', '<itemref idref="nav"/>', ...chapterFiles.map((_, i) => `<itemref idref="ch${i + 1}"/>`)].join('\n ')
const uuid = crypto.randomUUID()
zip.file(
'OEBPS/content.opf',
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="zh-CN">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">urn:uuid:${uuid}</dc:identifier>
<dc:title>${esc(title)}</dc:title>
<dc:language>zh-CN</dc:language>
<meta property="dcterms:modified">${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}</meta>
</metadata>
<manifest>
${manifest}
</manifest>
<spine>
${spine}
</spine>
</package>`
)
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
}
+24
View File
@@ -0,0 +1,24 @@
import type { ZodTypeAny, z } from 'zod'
/** 从模型输出中宽松地提取 JSON(容忍 markdown 代码块、前后缀文字) */
export function parseJsonLoose(text: string): unknown {
let t = text.trim()
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fence) t = fence[1].trim()
const starts = ['{', '['].map((c) => t.indexOf(c)).filter((i) => i !== -1)
if (starts.length === 0) throw new Error('输出中未找到 JSON')
const start = Math.min(...starts)
const end = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'))
if (end <= start) throw new Error('JSON 不完整')
return JSON.parse(t.slice(start, end + 1))
}
/** 解析 + zod 校验,失败抛出可读错误 */
export function parseStructured<S extends ZodTypeAny>(text: string, schema: S): z.output<S> {
const raw = parseJsonLoose(text)
const parsed = schema.safeParse(raw)
if (!parsed.success) {
throw new Error('JSON 结构校验失败: ' + parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')} ${i.message}`).join('; '))
}
return parsed.data
}
+15
View File
@@ -0,0 +1,15 @@
import type { Response } from 'express'
export function sseInit(res: Response) {
res.status(200).set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
res.flushHeaders?.()
}
export function sseSend(res: Response, event: string, data: unknown) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
+4
View File
@@ -0,0 +1,4 @@
/** 中文友好的字数统计:去除所有空白后的字符数 */
export function countWords(s: string): number {
return [...s.replace(/\s+/g, '')].length
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能小说工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"antd": "^5.22.2",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './pages/HomePage'
import WorkbenchPage from './pages/WorkbenchPage'
dayjs.locale('zh-cn')
export default function App() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: { colorPrimary: '#7c3aed', borderRadius: 6 }
}}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/workbench/:id" element={<WorkbenchPage />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
)
}
+93
View File
@@ -0,0 +1,93 @@
async function request<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined
})
if (!res.ok) {
let msg = `HTTP ${res.status}`
try {
const j = await res.json()
if (j?.error) msg = j.error
} catch {
/* ignore */
}
throw new Error(msg)
}
return res.json() as Promise<T>
}
export const apiGet = <T,>(p: string) => request<T>(p, 'GET')
export const apiPost = <T,>(p: string, body?: unknown) => request<T>(p, 'POST', body ?? {})
export const apiPatch = <T,>(p: string, body: unknown) => request<T>(p, 'PATCH', body)
export const apiPut = <T,>(p: string, body: unknown) => request<T>(p, 'PUT', body)
export const apiDelete = <T,>(p: string) => request<T>(p, 'DELETE')
export interface SseDoneMessage {
content?: string
data?: unknown
usage?: { prompt_tokens?: number; completion_tokens?: number }
}
export interface SseHandlers {
signal?: AbortSignal
onDelta?: (t: string) => void
onDone?: (m: SseDoneMessage) => void
onError?: (msg: string) => void
}
/** POST + SSE 流式响应解析(delta/done/error 三种事件) */
export async function ssePost(path: string, body: unknown, h: SseHandlers): Promise<void> {
let res: Response
try {
res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: h.signal
})
} catch (e) {
if ((e as Error).name !== 'AbortError') h.onError?.((e as Error).message)
return
}
if (!res.ok || !res.body) {
let msg = `HTTP ${res.status}`
try {
const j = await res.json()
if (j?.error) msg = j.error
} catch {
/* ignore */
}
h.onError?.(msg)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx: number
while ((idx = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, idx)
buf = buf.slice(idx + 2)
let event = 'message'
let data = ''
for (const line of chunk.split('\n')) {
if (line.startsWith('event: ')) event = line.slice(7)
else if (line.startsWith('data: ')) data += line.slice(6)
}
if (!data) continue
let msg: Record<string, unknown>
try {
msg = JSON.parse(data)
} catch {
continue
}
if (event === 'delta') h.onDelta?.(msg.text as string)
else if (event === 'done') h.onDone?.(msg as SseDoneMessage)
else if (event === 'error') h.onError?.(msg.message as string)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
export interface Project {
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
total_words?: number
chapter_count?: number
written_count?: number
}
export interface Character {
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
}
export interface WorldEntry {
id: number
project_id: number
category: string
title: string
content: string
sort: number
}
export interface Volume {
id: number
project_id: number
title: string
summary: string
sort: number
}
export interface Beat {
id: number
project_id: number
volume_id: number | null
sort: number
title: string
events: string
involved_characters: number[]
foreshadow_plan: string
pov: string
emotional_arc: string
purpose: string
tension: number
}
export interface Chapter {
id: number
project_id: number
beat_id: number | null
volume_id: number | null
sort: number
title: string
content: string
status: 'empty' | 'generated' | 'edited'
summary: string
word_count: number
updated_at: string
}
export interface Foreshadow {
id: number
project_id: number
description: string
planted_chapter_id: number | null
paid_chapter_id: number | null
status: 'planted' | 'paid'
notes: string
}
export interface TimelineEvent {
id: number
project_id: number
chapter_id: number | null
story_time: string
title: string
description: string
sort: number
}
export interface LlmSettings {
id: number
base_url: string
api_key: string
model: string
temperature: number
max_tokens: number
}
export interface Bundle {
project: Project
characters: Character[]
world: WorldEntry[]
volumes: Volume[]
beats: Beat[]
chapters: Chapter[]
foreshadows: Foreshadow[]
timeline: TimelineEvent[]
}
export interface PostprocessResult {
summary: string
new_world_facts: number
character_updates: number
new_foreshadows: number
paid_foreshadows: number
timeline_events: number
}
export interface GenState {
stage: string
label: string
text: string
done: boolean
}
export interface RewriteReq {
mode: 'rewrite' | 'expand' | 'shrink' | 'custom'
instruction?: string
selected: string
before: string
after: string
chapterId: number
start: number
end: number
}
+48
View File
@@ -0,0 +1,48 @@
import { Input } from 'antd'
import { useEffect, useState } from 'react'
/** 受控可编辑文本:外部值变化时同步,失焦时若变更则触发保存 */
export default function AutoSaveText({
value,
onSave,
multiline = false,
placeholder,
minRows = 2,
maxRows,
style,
bordered = true,
size
}: {
value: string
onSave: (next: string) => void
multiline?: boolean
placeholder?: string
minRows?: number
maxRows?: number
style?: React.CSSProperties
bordered?: boolean
size?: 'small' | 'middle' | 'large'
}) {
const [v, setV] = useState(value)
useEffect(() => setV(value), [value])
const commit = () => {
if (v !== value) onSave(v)
}
const common = {
value: v,
placeholder,
variant: bordered ? ('outlined' as const) : ('borderless' as const),
size,
style,
onChange: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => setV(e.target.value),
onBlur: commit,
onPressEnter: (e: React.KeyboardEvent) => {
if (!multiline) {
;(e.target as HTMLInputElement).blur()
}
}
}
return multiline ? <Input.TextArea autoSize={{ minRows, maxRows: maxRows ?? minRows + 6 }} {...common} /> : <Input {...common} />
}
+150
View File
@@ -0,0 +1,150 @@
import { DeleteOutlined, ThunderboltOutlined, EditOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Popconfirm, Rate, Select, Space, Tag, message } from 'antd'
import { apiDelete, apiPatch } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
const PURPOSES = ['开局', '铺垫', '升温', '转折', '高潮', '回落', '收束', '过渡']
/** 分章细纲:按卷查看/编辑每章的事件、人物与伏笔安排 */
export default function BeatsView({
bundle,
volumeId,
setVolumeId,
running,
onGenerate,
onChanged,
onWriteChapter
}: {
bundle: Bundle
volumeId: number | null
setVolumeId: (id: number | null) => void
running: boolean
onGenerate: (volumeId: number) => void
onChanged: () => void
onWriteChapter: (chapterId: number) => void
}) {
const volume = bundle.volumes.find((v) => v.id === volumeId) ?? bundle.volumes[0]
const activeId = volume?.id ?? null
const beats = bundle.beats.filter((b) => b.volume_id === activeId).sort((a, b) => a.sort - b.sort)
const saveBeat = (id: number, patch: Record<string, unknown>) => apiPatch(`/api/beats/${id}`, patch).catch((e) => message.error(e.message))
if (bundle.volumes.length === 0) {
return <Empty description="先生成或手动创建分卷大纲(大纲页),再为每卷生成分章细纲" style={{ marginTop: 60 }} />
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Select
value={activeId ?? undefined}
onChange={(v) => setVolumeId(v as number)}
style={{ minWidth: 220 }}
options={bundle.volumes.map((v, i) => ({ value: v.id, label: `第${i + 1}卷 ${v.title}` }))}
/>
<span style={{ color: '#999', fontSize: 13 }}>{volume?.summary?.slice(0, 60)}{volume && volume.summary.length > 60 ? '…' : ''}</span>
<div style={{ flex: 1 }} />
<Button type="primary" icon={<ThunderboltOutlined />} loading={running} disabled={!activeId} onClick={() => activeId && onGenerate(activeId)}>
{beats.length > 0 ? '重新生成本卷细纲' : 'AI 生成本卷细纲'}
</Button>
</div>
{beats.length === 0 ? (
<Empty description="本卷还没有分章细纲" style={{ marginTop: 40 }} />
) : (
beats.map((b, i) => {
const chapter = bundle.chapters.find((c) => c.beat_id === b.id)
return (
<Card
key={b.id}
size="small"
title={
<Space>
<Tag color="purple">{i + 1}</Tag>
<AutoSaveText value={b.title} onSave={(t) => saveBeat(b.id, { title: t })} bordered={false} style={{ fontWeight: 600, minWidth: 200 }} />
</Space>
}
extra={
<Space>
{chapter && (
<Button
size="small"
type={chapter.content.trim() ? 'default' : 'primary'}
icon={<EditOutlined />}
onClick={() => onWriteChapter(chapter.id)}
>
{chapter.content.trim() ? '查看正文' : '写正文'}
</Button>
)}
<Popconfirm title="删除该细纲?(对应章节保留)" onConfirm={() => apiDelete(`/api/beats/${b.id}`).then(onChanged)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
}
>
<AutoSaveText
value={b.events}
onSave={(t) => saveBeat(b.id, { events: t })}
multiline
minRows={2}
placeholder="本章事件:冲突、转折、结尾钩子"
style={{ color: '#444', marginBottom: 8 }}
/>
<Space wrap size={8} style={{ marginBottom: 8, rowGap: 4 }}>
<span style={{ color: '#999', fontSize: 12 }}>视角</span>
<Select
size="small"
allowClear
placeholder="视角人物"
style={{ minWidth: 110 }}
value={b.pov || undefined}
onChange={(v) => saveBeat(b.id, { pov: v ?? '' })}
options={bundle.characters.map((c) => ({ value: c.name, label: c.name }))}
/>
<span style={{ color: '#999', fontSize: 12 }}>功能</span>
<Select
size="small"
allowClear
placeholder="章节功能"
style={{ width: 96 }}
value={b.purpose || undefined}
onChange={(v) => saveBeat(b.id, { purpose: v ?? '' })}
options={PURPOSES.map((p) => ({ value: p, label: p }))}
/>
<span style={{ color: '#999', fontSize: 12 }}>张力</span>
<Rate count={5} value={b.tension} onChange={(v) => saveBeat(b.id, { tension: v })} style={{ fontSize: 14 }} />
<AutoSaveText
value={b.emotional_arc}
onSave={(t) => saveBeat(b.id, { emotional_arc: t })}
placeholder="情绪曲线:警惕→动摇→决意"
bordered={false}
style={{ color: '#7c3aed', minWidth: 200 }}
/>
</Space>
<Space wrap>
<Select
mode="multiple"
allowClear
placeholder="涉及人物"
style={{ minWidth: 260 }}
value={b.involved_characters}
onChange={(ids) => saveBeat(b.id, { involved_characters: ids as number[] })}
options={bundle.characters.map((c) => ({ value: c.id, label: `${c.name}(${c.role})` }))}
/>
<div style={{ flex: 1, minWidth: 260 }}>
<AutoSaveText
value={b.foreshadow_plan}
onSave={(t) => saveBeat(b.id, { foreshadow_plan: t })}
placeholder="伏笔安排(埋下 / 回收)"
bordered={false}
style={{ color: '#9966cc' }}
/>
</div>
</Space>
</Card>
)
})
)}
</div>
)
}
+298
View File
@@ -0,0 +1,298 @@
import { CheckCircleOutlined, EditOutlined, ExpandOutlined, LeftOutlined, RightOutlined, ScissorOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Empty, Input, Popover, Select, Space, Tag, Tooltip, Typography, message } from 'antd'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Bundle, Chapter, RewriteReq } from '../api/types'
/** 章节正文编辑器:自动保存 + 选中段落重写/扩写/缩写 + 章节快速导航 */
export default function ChapterEditor({
bundle,
chapter,
running,
onPatchChapter,
onGenerateProse,
onRewrite,
onSwitchChapter
}: {
bundle: Bundle
chapter: Chapter | undefined
running: boolean
onPatchChapter: (patch: { id: number; content?: string; title?: string }) => Promise<unknown>
onGenerateProse: (chapterId: number) => void
onRewrite: (req: RewriteReq) => void
onSwitchChapter: (chapterId: number) => void
}) {
const [content, setContent] = useState(chapter?.content ?? '')
const [title, setTitle] = useState(chapter?.title ?? '')
const [sel, setSel] = useState<{ start: number; end: number } | null>(null)
const [customOpen, setCustomOpen] = useState(false)
const [customText, setCustomText] = useState('')
const taRef = useRef<HTMLTextAreaElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dirtyRef = useRef(false)
const contentRef = useRef(content)
const chapterIdRef = useRef(chapter?.id)
const chapterId = chapter?.id
useEffect(() => {
contentRef.current = content
}, [content])
// 切换章节:重置本地编辑态;离开前把未保存内容立即落库
useEffect(() => {
chapterIdRef.current = chapterId
setContent(chapter?.content ?? '')
setTitle(chapter?.title ?? '')
dirtyRef.current = false
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (dirtyRef.current && chapterIdRef.current) {
dirtyRef.current = false
void onPatchChapter({ id: chapterIdRef.current, content: contentRef.current })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId])
// 外部内容更新(如采纳生成结果)时同步;用户正在编辑则不打扰
useEffect(() => {
if (!dirtyRef.current) setContent(chapter?.content ?? '')
}, [chapter?.content])
const beat = useMemo(() => (chapter?.beat_id ? bundle.beats.find((b) => b.id === chapter.beat_id) : undefined), [bundle.beats, chapter?.beat_id])
const chapterNo = useMemo(() => bundle.chapters.findIndex((c) => c.id === chapterId) + 1, [bundle.chapters, chapterId])
// 章节快速导航
const idx = useMemo(() => bundle.chapters.findIndex((c) => c.id === chapterId), [bundle.chapters, chapterId])
const prevChapter = idx > 0 ? bundle.chapters[idx - 1] : null
const nextChapter = idx >= 0 && idx < bundle.chapters.length - 1 ? bundle.chapters[idx + 1] : null
const chapterOptions = useMemo(
() =>
bundle.chapters.map((c, i) => ({
value: c.id,
// 细纲生成的标题通常已带「第N章」前缀,避免重复编号
label: /^第\s*\d+\s*章/.test(c.title) ? c.title : `第${i + 1}章 ${c.title}`
})),
[bundle.chapters]
)
const scheduleSave = (next: string) => {
dirtyRef.current = true
if (timerRef.current) clearTimeout(timerRef.current)
if (!chapterId) return
timerRef.current = setTimeout(() => {
onPatchChapter({ id: chapterId, content: next })
.then(() => {
if (contentRef.current === next) dirtyRef.current = false
})
.catch((e) => message.error('保存失败:' + e.message))
}, 800)
}
const flushSave = async () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (chapterId && dirtyRef.current) {
dirtyRef.current = false
await onPatchChapter({ id: chapterId, content })
}
}
if (!chapter) {
return (
<div style={{ display: 'grid', placeItems: 'center', flex: 1, minHeight: 300 }}>
<Empty description="从左侧选择或新建章节;生成细纲后会自动建立章节" />
</div>
)
}
const captureSelection = () => {
const ta = taRef.current
if (!ta) return
const { selectionStart, selectionEnd } = ta
setSel(selectionEnd > selectionStart ? { start: selectionStart, end: selectionEnd } : null)
}
const doRewrite = (mode: RewriteReq['mode'], instruction?: string) => {
if (!sel || !content.slice(sel.start, sel.end).trim()) return
void flushSave().then(() => {
onRewrite({
mode,
instruction,
selected: content.slice(sel.start, sel.end),
before: content.slice(Math.max(0, sel.start - 300), sel.start),
after: content.slice(sel.end, sel.end + 300),
chapterId: chapter.id,
start: sel.start,
end: sel.end
})
})
}
const selectedText = sel ? content.slice(sel.start, sel.end) : ''
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<Space.Compact>
<Tooltip title={prevChapter ? `上一章:${prevChapter.title}` : '没有上一章'}>
<Button size="small" icon={<LeftOutlined />} disabled={!prevChapter || running} onClick={() => prevChapter && onSwitchChapter(prevChapter.id)} />
</Tooltip>
<Select
size="small"
showSearch
optionFilterProp="label"
value={chapterId}
style={{ minWidth: 170 }}
onChange={onSwitchChapter}
options={chapterOptions}
popupMatchSelectWidth={false}
/>
<Tooltip title={nextChapter ? `下一章:${nextChapter.title}` : '没有下一章'}>
<Button size="small" icon={<RightOutlined />} disabled={!nextChapter || running} onClick={() => nextChapter && onSwitchChapter(nextChapter.id)} />
</Tooltip>
</Space.Compact>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => title !== chapter.title && onPatchChapter({ id: chapter.id, title })}
style={{ maxWidth: 380, fontWeight: 600 }}
placeholder="章节标题"
/>
<Tag>{chapterNo > 0 ? `第${chapterNo}章` : ''}</Tag>
<Tag color={chapter.status === 'empty' ? 'default' : chapter.status === 'generated' ? 'purple' : 'green'}>
{chapter.status === 'empty' ? '未写' : chapter.status === 'generated' ? 'AI 稿' : '已编辑'}
</Tag>
<span style={{ color: '#999', fontSize: 12 }}>{[...content.replace(/\s+/g, '')].length} 字</span>
<div style={{ flex: 1 }} />
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={running}
onClick={() => {
void flushSave().then(() => onGenerateProse(chapter.id))
}}
>
{chapter.content.trim() ? '重新生成本章' : '生成本章正文'}
</Button>
</div>
{beat && (
<div style={{ background: '#faf5ff', border: '1px solid #eee', borderRadius: 6, padding: '6px 12px', marginBottom: 8, fontSize: 13, color: '#555' }}>
<b style={{ color: '#7c3aed' }}>本章细纲:</b>
<span
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
color: '#666',
verticalAlign: 'bottom'
}}
>
{beat.events || '(无事件描述)'}
</span>
<div style={{ marginTop: 4, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{beat.pov && <Tag color="purple" style={{ marginInlineEnd: 0 }}>视角:{beat.pov}</Tag>}
{beat.purpose && <Tag color="geekblue" style={{ marginInlineEnd: 0 }}>功能:{beat.purpose}</Tag>}
{beat.tension > 0 && (
<Tag color="orange" style={{ marginInlineEnd: 0 }}>
张力:{'●'.repeat(beat.tension)}{'○'.repeat(5 - beat.tension)}
</Tag>
)}
{beat.emotional_arc && <Tag color="cyan" style={{ marginInlineEnd: 0 }}>情绪:{beat.emotional_arc}</Tag>}
{beat.foreshadow_plan && <Tag color="gold" style={{ marginInlineEnd: 0 }}>伏笔:{beat.foreshadow_plan}</Tag>}
</div>
</div>
)}
{sel && selectedText.trim() && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#f6f6f6', borderRadius: 6, padding: '6px 10px', marginBottom: 8, fontSize: 13 }}>
<span style={{ color: '#888' }}>
已选中 {selectedText.trim().length} 字:
</span>
<Space size={4}>
<Tooltip title="保持情节不变,提升文笔">
<Button size="small" icon={<EditOutlined />} disabled={running} onClick={() => doRewrite('rewrite')}>重写</Button>
</Tooltip>
<Tooltip title="补充细节,扩至 1.5-2 倍">
<Button size="small" icon={<ExpandOutlined />} disabled={running} onClick={() => doRewrite('expand')}>扩写</Button>
</Tooltip>
<Tooltip title="压缩至一半左右">
<Button size="small" icon={<ScissorOutlined />} disabled={running} onClick={() => doRewrite('shrink')}>缩写</Button>
</Tooltip>
<Popover
open={customOpen}
onOpenChange={setCustomOpen}
trigger={['click']}
content={
<div style={{ width: 280 }}>
<Input.TextArea
rows={2}
value={customText}
onChange={(e) => setCustomText(e.target.value)}
placeholder="例:改成第一人称;增加环境描写;语气更刻薄……"
/>
<Button
size="small"
type="primary"
style={{ marginTop: 8 }}
disabled={running || !customText.trim()}
onClick={() => {
setCustomOpen(false)
doRewrite('custom', customText.trim())
}}
>
执行改写
</Button>
</div>
}
>
<Button size="small" disabled={running}>自定义指令…</Button>
</Popover>
</Space>
<span style={{ color: '#bbb', fontSize: 12 }}>生成结果会出现在右侧抽屉中,确认后替换</span>
</div>
)}
<textarea
ref={taRef}
value={content}
onChange={(e) => {
setContent(e.target.value)
scheduleSave(e.target.value)
}}
onSelect={captureSelection}
onKeyUp={captureSelection}
placeholder="章节正文将生成或书写在这里。也可以先写好内容,再选中段落让 AI 重写。"
style={{
flex: 1,
minHeight: 300,
resize: 'none',
border: '1px solid #e5e5e5',
borderRadius: 6,
padding: 16,
fontSize: 15,
lineHeight: 2,
fontFamily: '"Microsoft YaHei", serif',
outline: 'none'
}}
/>
{chapter.summary && (
<div style={{ marginTop: 8, fontSize: 12, color: '#888', display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<CheckCircleOutlined style={{ color: '#52c41a', marginTop: 2 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<b>前情摘要(已注入后续章节生成):</b>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 0, display: 'inline' }}
ellipsis={{ rows: 1, expandable: true, symbol: '展开' }}
>
{chapter.summary}
</Typography.Paragraph>
</div>
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { DeleteOutlined, FileAddOutlined, FolderAddOutlined } from '@ant-design/icons'
import { Button, Input, Modal, Popconfirm, Tag, Tree, message } from 'antd'
import type { DataNode } from 'antd/es/tree'
import { useState } from 'react'
import { apiDelete, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
const STATUS_COLOR: Record<string, string> = { empty: '#d9d9d9', generated: '#7c3aed', edited: '#52c41a' }
const STATUS_TEXT: Record<string, string> = { empty: '未写', generated: 'AI 稿', edited: '已编辑' }
export default function ChapterTree({
bundle,
chapterId,
running,
onSelectChapter,
onSelectVolume,
onChanged
}: {
bundle: Bundle
chapterId: number | null
running: boolean
onSelectChapter: (id: number) => void
onSelectVolume: (id: number) => void
onChanged: () => void
}) {
const [newVolOpen, setNewVolOpen] = useState(false)
const [newVolName, setNewVolName] = useState('')
const [newChapterVol, setNewChapterVol] = useState<number | null | undefined>(undefined) // undefined=关闭, null=未分卷
const [newChapterTitle, setNewChapterTitle] = useState('')
const pid = bundle.project.id
const chapterNo = new Map(bundle.chapters.map((c, i) => [c.id, i + 1]))
const buildTree = (): DataNode[] => {
const chapterNode = (c: Bundle['chapters'][number]): DataNode => ({
key: `c-${c.id}`,
title: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, maxWidth: 230 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{chapterNo.get(c.id)}. {c.title}
</span>
<Tag color={STATUS_COLOR[c.status]} style={{ marginInlineEnd: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
{STATUS_TEXT[c.status]}
</Tag>
<Popconfirm title="删除该章节?" onConfirm={() => apiDelete(`/api/chapters/${c.id}`).then(onChanged)} onCancel={(e) => e?.stopPropagation()}>
<DeleteOutlined
style={{ color: '#bbb', fontSize: 12 }}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</span>
)
})
const nodes: DataNode[] = bundle.volumes.map((v) => ({
key: `v-${v.id}`,
title: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{v.title}</b>
<FileAddOutlined
style={{ color: '#999' }}
onClick={(e) => {
e.stopPropagation()
setNewChapterVol(v.id)
setNewChapterTitle('')
}}
/>
<Popconfirm title="删除该卷?(章节保留为未分卷)" onConfirm={() => apiDelete(`/api/volumes/${v.id}`).then(onChanged)}>
<DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</span>
),
children: bundle.chapters.filter((c) => c.volume_id === v.id).map(chapterNode)
}))
const orphans = bundle.chapters.filter((c) => c.volume_id === null)
if (orphans.length > 0) nodes.push({ key: 'v-none', title: <b>未分卷</b>, children: orphans.map(chapterNode) })
return nodes
}
const createChapter = async () => {
if (!newChapterTitle.trim()) return
const r = await apiPost<{ id: number }>(`/api/projects/${pid}/chapters`, {
volume_id: newChapterVol,
beat_id: null,
title: newChapterTitle.trim(),
sort: bundle.chapters.length + 1
})
setNewChapterVol(undefined)
onChanged()
onSelectChapter(r.id)
message.success('章节已创建')
}
return (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<b>章节树</b>
<span style={{ display: 'flex', gap: 4 }}>
<Button size="small" icon={<FolderAddOutlined />} disabled={running} onClick={() => { setNewVolName(`第${bundle.volumes.length + 1}卷`); setNewVolOpen(true) }}>
卷
</Button>
<Button size="small" icon={<FileAddOutlined />} disabled={running} onClick={() => { setNewChapterVol(null); setNewChapterTitle('新章节') }}>
章
</Button>
</span>
</div>
{bundle.chapters.length === 0 && bundle.volumes.length === 0 ? (
<div style={{ color: '#999', fontSize: 12, padding: '8px 0' }}>
还没有章节。请先生成大纲与细纲,或手动新建。
</div>
) : (
<Tree
blockNode
defaultExpandAll
expandAction={false}
selectedKeys={chapterId ? [`c-${chapterId}`] : []}
onSelect={(keys, info) => {
const key = String(keys[0] ?? '')
if (key.startsWith('c-')) onSelectChapter(Number(key.slice(2)))
else if (key.startsWith('v-') && key !== 'v-none') onSelectVolume(Number(key.slice(2)))
void info
}}
treeData={buildTree()}
/>
)}
<Modal
title="新建卷"
open={newVolOpen}
onCancel={() => setNewVolOpen(false)}
onOk={async () => {
if (!newVolName.trim()) return
await apiPost(`/api/projects/${pid}/volumes`, { title: newVolName.trim(), summary: '', sort: bundle.volumes.length + 1 })
setNewVolOpen(false)
onChanged()
}}
>
<Input value={newVolName} onChange={(e) => setNewVolName(e.target.value)} placeholder="卷名" />
</Modal>
<Modal
title={newChapterVol === null ? '新建章节(未分卷)' : '新建章节'}
open={newChapterVol !== undefined}
onCancel={() => setNewChapterVol(undefined)}
onOk={createChapter}
>
<Input value={newChapterTitle} onChange={(e) => setNewChapterTitle(e.target.value)} placeholder="章节标题,如:第1章 异变" />
</Modal>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useRef } from 'react'
import { Alert, Button, Drawer, Space, Tag, Typography } from 'antd'
import type { GenState } from '../api/types'
/** JSON 结构化阶段:抽屉里不展示原始 JSON,只展示可读的实时进度 */
const STRUCTURED_STAGES = new Set(['characters', 'world', 'volumes', 'beats'])
const NAME_PATTERNS: Record<string, RegExp> = {
characters: /"name"\s*:\s*"([^"]+)"/g,
world: /"title"\s*:\s*"([^"]+)"/g,
volumes: /"title"\s*:\s*"([^"]+)"/g,
beats: /"title"\s*:\s*"([^"]+)"/g
}
/** 提取标题中的序号(卷一/第1卷/第12章):模型输出多版本方案时只保留第一套 */
function ordinalOf(name: string, stage: string): string | null {
const t = name.replace(/\s+/g, '')
if (stage === 'beats') return /^第([0-90-9一二三四五六七八九十百]+)[章回]/.exec(t)?.[1] ?? null
if (stage === 'volumes') {
const num = '[0-90-9一二三四五六七八九十百]+'
const m = new RegExp(`^(?:第)?(${num})[卷部篇]`).exec(t) ?? new RegExp(`^[卷部篇](${num})`).exec(t)
return m?.[1] ?? null
}
return null
}
const normKey = (s: string) => s.replace(/[\s::||·、,,。.\-—~~()()【】[\]]+/g, '')
function extractNames(stage: string, text: string): string[] {
const re = NAME_PATTERNS[stage]
if (!re) return []
const out: string[] = []
const seenOrd = new Set<string>()
const seenText = new Set<string>()
for (const m of text.matchAll(re)) {
const name = m[1].trim()
if (!name) continue
const ord = ordinalOf(name, stage)
if (ord) {
if (seenOrd.has(ord)) continue
seenOrd.add(ord)
} else {
const key = normKey(name)
if (seenText.has(key)) continue
seenText.add(key)
}
out.push(name)
}
return out
}
const STAGE_UNIT: Record<string, string> = { characters: '人物', world: '条设定', volumes: '卷', beats: '章细纲' }
export default function GenerateDrawer({
gen,
running,
onAccept,
onDiscard,
onAbort
}: {
gen: GenState | null
running: boolean
onAccept: () => void
onDiscard: () => void
onAbort: () => void
}) {
const bodyRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [gen?.text])
const structured = gen ? STRUCTURED_STAGES.has(gen.stage) : false
const names = useMemo(() => (gen && structured ? extractNames(gen.stage, gen.text) : []), [gen, structured])
const unit = gen ? (STAGE_UNIT[gen.stage] ?? '项') : ''
const canAccept = !!gen && (!structured || gen.done)
return (
<Drawer
open={!!gen}
onClose={() => {
if (running) onAbort()
onDiscard()
}}
title={gen?.label}
width={520}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{gen && structured && !gen.done ? `已输出 ${[...gen.text.replace(/\s/g, '')].length} 字…` : ''}
</Typography.Text>
<Space>
{running ? (
<Button onClick={onAbort}>中断生成</Button>
) : (
<>
<Button onClick={onDiscard}>丢弃</Button>
<Button type="primary" disabled={!canAccept} onClick={onAccept}>
采纳
</Button>
</>
)}
</Space>
</div>
}
>
<div ref={bodyRef} style={{ height: '100%', overflow: 'auto' }}>
{gen && structured ? (
<>
<Alert
type={gen.done ? 'success' : 'info'}
showIcon={false}
message={
gen.done
? `生成完成:共 ${names.length} ${unit}。检查无误后点击「采纳」保存入库。`
: `AI 正在生成${unit}…已出现 ${names.length} 项`
}
style={{ marginBottom: 16 }}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{names.map((n, i) => (
<Tag key={`${n}-${i}`} color={gen.done ? 'purple' : 'default'}>
{n}
</Tag>
))}
</div>
{running && <span style={{ color: '#7c3aed' }}>▌</span>}
</>
) : (
<>
<pre className="stream-pre">{gen?.text}</pre>
{running && <span style={{ color: '#7c3aed' }}>▌</span>}
</>
)}
</div>
</Drawer>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { Form, Input, InputNumber, Modal, Select, Space, message } from 'antd'
import { apiPost } from '../api/client'
import type { Project } from '../api/types'
const GENRES = ['玄幻', '仙侠', '都市', '科幻', '悬疑', '历史', '言情', '奇幻', '武侠', '游戏', '其他']
export default function NewProjectModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (p: Project) => void }) {
const [form] = Form.useForm()
const submit = async () => {
try {
const values = await form.validateFields()
const p = await apiPost<Project>('/api/projects', values)
onCreated(p)
form.resetFields()
} catch (e) {
// 表单校验错误(有 message 字段)已由表单展示,这里只提示服务端错误
if ((e as { errorFields?: unknown }).errorFields === undefined) message.error((e as Error).message)
throw e
}
}
return (
<Modal
title="新建作品"
open={open}
onCancel={onClose}
onOk={submit}
okText="创建并进入工作台"
width={640}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ target_chapter_words: 3000, target_chapter_count: 30 }}>
<Form.Item name="name" label="作品名" rules={[{ required: true, message: '请输入作品名' }]}>
<Input placeholder="例:星海遗民" />
</Form.Item>
<Form.Item name="genre" label="题材">
<Select placeholder="选择题材" allowClear options={GENRES.map((g) => ({ value: g, label: g }))} />
</Form.Item>
<Form.Item
name="premise"
label="故事创意 / 基础设定(可粘贴长文:背景、主角、核心冲突、期望走向、已有设定均可)"
rules={[{ required: true, message: '请描述你的故事创意' }]}
extra="支持粘贴数万字以内的设定材料,AI 会在生成时提取要点"
>
<Input.TextArea rows={6} placeholder="例:一艘世代飞船上,主角发现了飞船早已抵达目的地、所谓航行只是统治谎言的证据……" />
</Form.Item>
<Form.Item name="style_notes" label="文风要求(可选)">
<Input.TextArea rows={2} placeholder="例:克制的冷峻笔调,多对话少心理描写,类似《三体》的硬朗感" />
</Form.Item>
<Form.Item name="style_sample" label="文风示例片段(可选,AI 会模仿其笔法)">
<Input.TextArea rows={3} placeholder="粘贴一段你喜欢文风的示例文字(300-800 字效果最佳)" />
</Form.Item>
<Form.Item name="narrative_pov" label="叙事视角(可选)" extra="正文生成时会严格执行视角纪律,不漂移">
<Select
placeholder="选择或留空由 AI 酌情处理"
allowClear
options={['第三人称限知', '第三人称全知', '第一人称', '多视角轮转'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="taboos" label="禁用内容(可选)" extra="例:不要重生穿越;不要系统流;不出现真实政治人物">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="目标规模" required style={{ marginBottom: 0 }}>
<Space>
<Form.Item name="target_chapter_count" noStyle>
<InputNumber min={1} max={2000} addonAfter="章" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="target_chapter_words" noStyle>
<InputNumber min={200} max={20000} addonAfter="字/章" style={{ width: 160 }} />
</Form.Item>
</Space>
</Form.Item>
</Form>
</Modal>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Input, Popconfirm, message } from 'antd'
import { apiDelete, apiPatch, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
/** 分卷大纲:编辑卷信息 + AI 生成总纲 */
export default function OutlineView({
bundle,
running,
onGenerate,
onChanged
}: {
bundle: Bundle
running: boolean
onGenerate: () => void
onChanged: () => void
}) {
const pid = bundle.project.id
const saveVolume = (id: number, patch: Record<string, unknown>) => apiPatch(`/api/volumes/${id}`, patch).catch((e) => message.error(e.message))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#888', fontSize: 13 }}>
大纲决定整本书的骨架。目标 {bundle.project.target_chapter_count} 章 · 生成后可在下方直接修改,再到「细纲」页生成分章细纲
</span>
<Button type="primary" icon={<ThunderboltOutlined />} loading={running} onClick={onGenerate}>
{bundle.volumes.length > 0 ? '重新生成总纲' : 'AI 生成分卷大纲'}
</Button>
</div>
{bundle.volumes.length === 0 ? (
<Empty description="还没有分卷大纲" style={{ marginTop: 60 }} />
) : (
bundle.volumes.map((v, i) => (
<Card
key={v.id}
size="small"
title={`第${i + 1}卷`}
extra={
<Popconfirm title="删除该卷?(章节保留为未分卷)" onConfirm={() => apiDelete(`/api/volumes/${v.id}`).then(onChanged)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<div style={{ marginBottom: 6 }}>
<AutoSaveText value={v.title} onSave={(t) => saveVolume(v.id, { title: t })} placeholder="卷名" bordered={false} style={{ fontWeight: 700, fontSize: 15 }} />
</div>
<AutoSaveText value={v.summary} onSave={(s) => saveVolume(v.id, { summary: s })} multiline minRows={2} placeholder="本卷大纲:核心冲突、关键事件、结尾钩子" style={{ color: '#555' }} />
<div style={{ marginTop: 6, color: '#bbb', fontSize: 12 }}>
本卷 {bundle.chapters.filter((c) => c.volume_id === v.id).length} 章(生成细纲后自动创建)
</div>
</Card>
))
)}
{bundle.volumes.length > 0 && (
<Button
style={{ alignSelf: 'flex-start' }}
onClick={async () => {
await apiPost(`/api/projects/${pid}/volumes`, { title: `第${bundle.volumes.length + 1}卷`, summary: '', sort: bundle.volumes.length + 1 })
onChanged()
}}
>
手动添加一卷
</Button>
)}
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { ThunderboltOutlined } from '@ant-design/icons'
import { Button, Steps, Tag } from 'antd'
import type { Bundle } from '../api/types'
type GuideTab = 'prose' | 'outline' | 'beats' | null
interface StepDef {
key: string
title: string
desc: string
done: boolean
tab: GuideTab
ai: string
you: string
actions?: { label: string; primary?: boolean; onClick: () => void }[]
}
/** 常驻流水线引导:每一步都标明「AI 生成什么 / 人工审核什么」,完成一步自动亮起下一步 */
export default function PipelineGuide({
bundle,
running,
onGoto,
onGenerateCharacters,
onGenerateWorld
}: {
bundle: Bundle
running: boolean
onGoto: (tab: 'prose' | 'outline' | 'beats') => void
onGenerateCharacters: () => void
onGenerateWorld: () => void
}) {
const written = bundle.chapters.filter((c) => c.content.trim()).length
const steps: StepDef[] = [
{
key: 'bible',
title: '1 人物与世界观',
desc: bundle.characters.length + bundle.world.length > 0 ? `${bundle.characters.length} 人物 · ${bundle.world.length} 设定` : '待生成',
done: bundle.characters.length > 0 || bundle.world.length > 0,
tab: null,
ai: '根据故事创意生成人物卡与世界观候选',
you: '在右侧「故事圣经」逐项审核修改——这是全书一致性的地基',
actions: [
{ label: '生成人物卡', primary: true, onClick: onGenerateCharacters },
{ label: '生成世界观', onClick: onGenerateWorld }
]
},
{
key: 'volumes',
title: '2 分卷大纲',
desc: bundle.volumes.length > 0 ? `${bundle.volumes.length} 卷` : '待生成',
done: bundle.volumes.length > 0,
tab: 'outline',
ai: '基于人物与世界观搭出分卷骨架与主线节奏',
you: '在「大纲」页审核每卷走向并直接改文字;不满意可带要求重新生成',
actions: [{ label: '去大纲页生成', primary: true, onClick: () => onGoto('outline') }]
},
{
key: 'beats',
title: '3 分章细纲',
desc: bundle.beats.length > 0 ? `${bundle.beats.length} 章` : '待生成',
done: bundle.beats.length > 0,
tab: 'beats',
ai: '把每卷拆成逐章细纲:事件 / 涉及人物 / 伏笔安排',
you: '在「细纲」页逐章把关:调整事件顺序、涉及人物与伏笔节奏',
actions: [{ label: '去细纲页生成', primary: true, onClick: () => onGoto('beats') }]
},
{
key: 'prose',
title: '4 逐章正文',
desc: written > 0 ? `已写 ${written} 章` : '待写作',
done: written > 0,
tab: 'prose',
ai: '注入人物卡、前情摘要、伏笔与上一章结尾,逐章流式生成',
you: '选中段落用「重写 / 扩写 / 缩写」微调,正文只需少量人工修订',
actions: [{ label: '去写正文', primary: true, onClick: () => onGoto('prose') }]
},
{
key: 'export',
title: '5 回写与导出',
desc: 'TXT / EPUB',
done: written > 0,
tab: null,
ai: '每章生成后自动回写摘要、新设定、伏笔与时间线',
you: '在右侧「故事圣经」核对伏笔与时间线,完成后从右上角导出'
}
]
const currentIdx = steps.findIndex((s) => !s.done)
const cur = currentIdx === -1 ? null : steps[currentIdx]
return (
<div style={{ background: '#fff', borderBottom: '1px solid #f0f0f0', padding: '10px 20px 10px' }}>
<Steps
size="small"
current={currentIdx === -1 ? steps.length : currentIdx}
onChange={(i) => {
const t = steps[i]?.tab
if (t) onGoto(t)
}}
items={steps.map((s) => ({ title: s.title, description: s.desc }))}
/>
{cur ? (
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', fontSize: 13, lineHeight: '22px' }}>
<Tag color="purple" style={{ marginInlineEnd: 0 }}>AI</Tag>
<span style={{ color: '#555' }}>{cur.ai}</span>
<Tag color="green" style={{ marginInlineEnd: 0 }}>你</Tag>
<span style={{ color: '#555' }}>{cur.you}</span>
<div style={{ flex: 1 }} />
{cur.actions?.map((a) => (
<Button
key={a.label}
size="small"
type={a.primary ? 'primary' : 'default'}
icon={<ThunderboltOutlined />}
disabled={running}
onClick={a.onClick}
>
{a.label}
</Button>
))}
</div>
) : (
<div style={{ marginTop: 6, fontSize: 13, color: '#52c41a' }}>
✓ 全流程已就绪:继续逐章写作与微调,随时可从右上角导出 TXT / EPUB
</div>
)}
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { Alert, Button, Form, Input, InputNumber, Modal, Spin } from 'antd'
import { useEffect, useState } from 'react'
import { apiGet, apiPost, apiPut } from '../api/client'
import type { LlmSettings } from '../api/types'
interface TestResult {
ok: boolean
reply?: string
models?: string[]
error?: string
}
export default function SettingsModal({ open, onClose, onSaved }: { open: boolean; onClose: () => void; onSaved?: () => void }) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
useEffect(() => {
if (!open) return
setTestResult(null)
setLoading(true)
apiGet<LlmSettings>('/api/settings')
.then((s) => form.setFieldsValue(s))
.catch((e) => e)
.finally(() => setLoading(false))
}, [open, form])
const runTest = async () => {
try {
const values = await form.validateFields()
setTesting(true)
setTestResult(null)
const r = await apiPost<TestResult>('/api/settings/test', {
base_url: values.base_url,
api_key: values.api_key,
model: values.model
})
setTestResult(r)
} catch (e) {
setTestResult({ ok: false, error: (e as Error).message })
} finally {
setTesting(false)
}
}
const save = async () => {
const values = await form.validateFields()
await apiPut('/api/settings', values)
onSaved?.()
onClose()
}
return (
<Modal title="模型接入设置" open={open} onCancel={onClose} width={640}
footer={[
<Button key="test" onClick={runTest} loading={testing}>测试连接</Button>,
<Button key="cancel" onClick={onClose}>取消</Button>,
<Button key="save" type="primary" onClick={save}>保存</Button>
]}
>
<Spin spinning={loading}>
<Alert
style={{ marginBottom: 16 }}
type="info"
showIcon
message="支持任意 OpenAI 兼容接口(火山方舟 / DeepSeek / OpenAI / 本地 Ollama 等)。保存后即可生成。"
/>
<Form form={form} layout="vertical" initialValues={{ temperature: 0.8, max_tokens: 12000 }}>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true, message: '请输入 Base URL' }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" />
</Form.Item>
<Form.Item name="api_key" label="API Key" rules={[{ required: true, message: '请输入 API Key' }]}>
<Input.Password placeholder="ark-..." visibilityToggle />
</Form.Item>
<Form.Item name="model" label="模型名" rules={[{ required: true, message: '请输入模型名' }]} extra="火山方舟填模型名(如 doubao-seed-1-6-flash);其他服务商按其文档填写">
<Input placeholder="doubao-seed-1-6-flash" />
</Form.Item>
<Form.Item name="temperature" label="温度(越高越有创造性)">
<InputNumber min={0} max={2} step={0.1} style={{ width: 160 }} />
</Form.Item>
<Form.Item name="max_tokens" label="单次最大输出 tokens" extra="章节正文较长,建议保持 12000 以上">
<InputNumber min={256} max={65536} step={256} style={{ width: 160 }} />
</Form.Item>
</Form>
{testResult && (
<Alert
type={testResult.ok ? 'success' : 'error'}
showIcon
message={testResult.ok ? `连接成功,模型回复:${testResult.reply || '(空)'}` : `连接失败:${testResult.error}`}
description={testResult.ok && testResult.models ? `可用模型(前 ${testResult.models.length} 个):${testResult.models.join('、') || '(无)'}` : undefined}
style={{ marginTop: 8 }}
/>
)}
</Spin>
</Modal>
)
}
+349
View File
@@ -0,0 +1,349 @@
import { DeleteOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { Button, Collapse, Empty, Input, Popconfirm, Select, Space, Tabs, Tag, Timeline, message } from 'antd'
import { useState } from 'react'
import { apiDelete, apiPatch, apiPost } from '../api/client'
import type { Bundle } from '../api/types'
import AutoSaveText from './AutoSaveText'
const WORLD_CATEGORIES = ['地理', '势力', '力量体系', '物品', '规则', '历史', '其他']
const ROLES = ['主角', '重要配角', '配角', '反派']
const oneLine = (s: string, n = 30) => {
const t = (s || '').replace(/\s+/g, ' ').trim()
return t.length > n ? t.slice(0, n) + '…' : t
}
/** 故事圣经:人物卡 / 世界观 / 伏笔 / 时间线,条目默认收起,点击展开编辑 */
export default function StoryBible({ bundle, onChanged, running }: { bundle: Bundle; onChanged: () => void; running?: boolean }) {
const pid = bundle.project.id
const [expanded, setExpanded] = useState<string[]>([])
const [backfilling, setBackfilling] = useState(false)
const guard = (p: Promise<unknown>) =>
p.then(onChanged).catch((e) => {
message.error(e.message)
throw e
})
const toggle = (key: string) =>
setExpanded((cur) => (cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]))
// 旧版生成的卡片缺少声口 / 内在冲突 / 秘密,可一键让 AI 补全(只填空字段)
const missingCount = bundle.characters.filter((c) => !c.speech_pattern.trim() || !c.inner_conflict.trim() || !c.secret.trim()).length
const backfillCraft = async () => {
setBackfilling(true)
const hide = message.loading(`AI 正在为 ${missingCount} 位人物补写声口 / 内在冲突 / 秘密…`, 0)
try {
const r = await apiPost<{ updated: number; message?: string }>('/api/generate/backfill/characters', { projectId: pid })
if (r.updated === 0) message.info(r.message ?? '所有人物卡均已补全')
else message.success(`已为 ${r.updated} 位人物补全专业字段,展开人物卡查看修改`)
onChanged()
} catch (e) {
message.error('补全失败:' + (e as Error).message)
} finally {
hide()
setBackfilling(false)
}
}
const charactersTab = (
<div style={{ padding: '0 2px 12px' }}>
{missingCount > 0 && (
<Button
block
size="small"
icon={<ThunderboltOutlined />}
style={{ marginBottom: 8 }}
loading={backfilling}
disabled={running}
onClick={backfillCraft}
>
AI 补全声口 / 内在冲突 / 秘密(待补 {missingCount} 人)
</Button>
)}
{bundle.characters.length === 0 && <Empty description="暂无人物卡" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{bundle.characters.map((c) => {
const key = `c${c.id}`
const open = expanded.includes(key)
return (
<Collapse
key={c.id}
size="small"
style={{ marginBottom: 6, border: '1px solid #f0f0f0', borderRadius: 6 }}
activeKey={open ? [key] : []}
onChange={() => toggle(key)}
items={[
{
key,
label: (
<Space size={6}>
<b>{c.name}</b>
<Tag color={c.role === '主角' ? 'purple' : c.role === '反派' ? 'red' : 'default'} style={{ marginInlineEnd: 0 }}>
{c.role}
</Tag>
<span style={{ color: '#999', fontSize: 12, fontWeight: 400 }}>{oneLine(c.goals || c.personality)}</span>
</Space>
),
extra: (
<Popconfirm
title="删除该人物?"
onConfirm={(e) => {
e?.stopPropagation()
guard(apiDelete(`/api/characters/${c.id}`))
}}
onCancel={(e) => e?.stopPropagation()}
>
<DeleteOutlined style={{ color: '#bbb' }} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
),
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13 }}>
<Space.Compact block>
<Input
defaultValue={c.name}
onBlur={(e) => e.target.value !== c.name && guard(apiPatch(`/api/characters/${c.id}`, { name: e.target.value }))}
addonBefore="姓名"
size="small"
/>
<Select
defaultValue={c.role}
size="small"
style={{ width: 110 }}
onChange={(role) => guard(apiPatch(`/api/characters/${c.id}`, { role }))}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Space.Compact>
{(
[
['aliases', '别称'],
['appearance', '外貌'],
['personality', '性格'],
['background', '背景'],
['goals', '目标(表层目标与深层需求)'],
['speech_pattern', '语言习惯 / 口癖(保证声口一致)'],
['inner_conflict', '内在冲突(欲望 vs 恐惧)'],
['secret', '秘密(不为人知之事)']
] as const
).map(([field, label]) => (
<div key={field}>
<div style={{ color: '#999', fontSize: 12 }}>{label}</div>
<AutoSaveText
value={c[field]}
onSave={(v) => guard(apiPatch(`/api/characters/${c.id}`, { [field]: v }))}
multiline={field !== 'aliases'}
minRows={2}
bordered={false}
placeholder={label}
style={{ fontSize: 13 }}
/>
</div>
))}
<div>
<div style={{ color: '#999', fontSize: 12 }}>弧光与近况(AI 生成正文后自动追加)</div>
<AutoSaveText
value={c.arc_notes}
onSave={(v) => guard(apiPatch(`/api/characters/${c.id}`, { arc_notes: v }))}
multiline
minRows={2}
bordered={false}
placeholder="人物成长线与最新状态"
style={{ fontSize: 12, color: '#666' }}
/>
</div>
</div>
)
}
]}
/>
)
})}
<Button
block
icon={<PlusOutlined />}
onClick={() => guard(apiPost(`/api/projects/${pid}/characters`, { name: '新人物', role: '配角', sort: bundle.characters.length + 1 }))}
>
添加人物
</Button>
</div>
)
// 动态分组:预置分类靠前,AI 自创分类按出现顺序排在后面;空分类归入「未分类」,保证所有条目可见
const worldGroups = (() => {
const catOf = (w: Bundle['world'][number]) => (w.category || '').trim() || '未分类'
const cats: string[] = []
bundle.world.forEach((w) => {
const c = catOf(w)
if (!cats.includes(c)) cats.push(c)
})
cats.sort((a, b) => {
const ia = WORLD_CATEGORIES.indexOf(a)
const ib = WORLD_CATEGORIES.indexOf(b)
return (ia === -1 ? WORLD_CATEGORIES.length : ia) - (ib === -1 ? WORLD_CATEGORIES.length : ib)
})
return cats.map((cat) => ({ cat, items: bundle.world.filter((w) => catOf(w) === cat) }))
})()
const worldTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.world.length === 0 && <Empty description="暂无世界观设定" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{worldGroups.map((g) => (
<div key={g.cat} style={{ marginBottom: 10 }}>
<Tag color="geekblue">{g.cat}</Tag>
<Collapse
size="small"
ghost
items={g.items.map((w) => ({
key: `w${w.id}`,
label: (
<span>
<b style={{ fontSize: 13 }}>{w.title}</b>
<span style={{ color: '#999', fontSize: 12, marginLeft: 8 }}>{oneLine(w.content)}</span>
</span>
),
extra: <Popconfirm title="删除该设定?" onConfirm={() => guard(apiDelete(`/api/world/${w.id}`))}><DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} /></Popconfirm>,
children: (
<div>
<AutoSaveText value={w.title} onSave={(t) => guard(apiPatch(`/api/world/${w.id}`, { title: t }))} bordered={false} style={{ fontWeight: 600 }} placeholder="条目名" />
<AutoSaveText
value={w.content}
onSave={(v) => guard(apiPatch(`/api/world/${w.id}`, { content: v }))}
multiline
minRows={3}
bordered={false}
placeholder="具体设定描述"
style={{ fontSize: 12, color: '#666' }}
/>
</div>
)
}))}
/>
</div>
))}
<Button
block
icon={<PlusOutlined />}
onClick={() => guard(apiPost(`/api/projects/${pid}/world`, { title: '新设定', category: '其他', content: '', sort: bundle.world.length + 1 }))}
>
添加设定
</Button>
</div>
)
const chapterTitle = (id: number | null) => {
if (!id) return ''
const idx = bundle.chapters.findIndex((c) => c.id === id)
return idx >= 0 ? `第${idx + 1}章《${bundle.chapters[idx].title}》` : ''
}
const foreshadowTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.foreshadows.length === 0 && <Empty description="暂无伏笔。正文中 AI 侦测到伏笔会自动登记" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
<Collapse
size="small"
items={bundle.foreshadows.map((f) => ({
key: `f${f.id}`,
label: (
<Space size={6}>
<Tag color={f.status === 'planted' ? 'orange' : 'green'} style={{ marginInlineEnd: 0 }}>
{f.status === 'planted' ? '未回收' : '已回收'}
</Tag>
<span style={{ fontSize: 13 }}>{oneLine(f.description, 26)}</span>
</Space>
),
extra: <Popconfirm title="删除该伏笔?" onConfirm={() => guard(apiDelete(`/api/foreshadows/${f.id}`))}><DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} /></Popconfirm>,
children: (
<div>
<AutoSaveText value={f.description} onSave={(v) => guard(apiPatch(`/api/foreshadows/${f.id}`, { description: v }))} multiline minRows={2} bordered={false} style={{ fontSize: 13 }} placeholder="伏笔描述" />
<Space style={{ marginTop: 4 }}>
<Select
size="small"
value={f.status}
style={{ width: 92 }}
onChange={(status) => guard(apiPatch(`/api/foreshadows/${f.id}`, { status }))}
options={[
{ value: 'planted', label: '未回收' },
{ value: 'paid', label: '已回收' }
]}
/>
<span style={{ fontSize: 11, color: '#bbb' }}>
{chapterTitle(f.planted_chapter_id)} 埋设{f.status === 'paid' ? ` · ${chapterTitle(f.paid_chapter_id)} 回收` : ''}
</span>
</Space>
</div>
)
}))}
/>
<Button
block
icon={<PlusOutlined />}
style={{ marginTop: 8 }}
onClick={() => guard(apiPost(`/api/projects/${pid}/foreshadows`, { description: '新伏笔:', status: 'planted' }))}
>
手动登记伏笔
</Button>
</div>
)
const timelineTab = (
<div style={{ padding: '0 2px 12px' }}>
{bundle.timeline.length === 0 && <Empty description="暂无时间线事件。正文生成后 AI 会自动记录关键事件" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{bundle.timeline.length > 0 && (
<Timeline
mode="left"
items={bundle.timeline.map((t) => ({
color: t.chapter_id ? 'purple' : 'gray',
label: (
<Input
defaultValue={t.story_time}
size="small"
placeholder="故事时间"
style={{ width: 100 }}
onBlur={(e) => e.target.value !== t.story_time && guard(apiPatch(`/api/timeline/${t.id}`, { story_time: e.target.value }))}
/>
),
children: (
<div style={{ paddingBottom: 2 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<AutoSaveText value={t.title} onSave={(v) => guard(apiPatch(`/api/timeline/${t.id}`, { title: v }))} bordered={false} style={{ fontWeight: 600, flex: 1 }} placeholder="事件名" />
<Popconfirm title="删除该事件?" onConfirm={() => guard(apiDelete(`/api/timeline/${t.id}`))}>
<DeleteOutlined style={{ color: '#bbb', fontSize: 12 }} />
</Popconfirm>
</div>
<AutoSaveText
value={t.description}
onSave={(v) => guard(apiPatch(`/api/timeline/${t.id}`, { description: v }))}
multiline
minRows={1}
bordered={false}
placeholder="事件详情"
style={{ fontSize: 12, color: '#666' }}
/>
<div style={{ fontSize: 11, color: '#bbb' }}>{chapterTitle(t.chapter_id)}</div>
</div>
)
}))}
/>
)}
<Button
block
icon={<PlusOutlined />}
style={{ marginTop: 8 }}
onClick={() => guard(apiPost(`/api/projects/${pid}/timeline`, { title: '新事件', story_time: '', sort: bundle.timeline.length + 1 }))}
>
添加事件
</Button>
</div>
)
return (
<div style={{ padding: '6px 12px 16px' }}>
<Tabs
items={[
{ key: 'characters', label: `人物 ${bundle.characters.length}`, children: charactersTab },
{ key: 'world', label: `世界观 ${bundle.world.length}`, children: worldTab },
{ key: 'foreshadows', label: `伏笔 ${bundle.foreshadows.length}`, children: foreshadowTab },
{ key: 'timeline', label: `时间线 ${bundle.timeline.length}`, children: timelineTab }
]}
/>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
box-sizing: border-box;
}
.stream-pre {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 13px;
line-height: 1.8;
margin: 0;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'antd/dist/reset.css'
import './global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+122
View File
@@ -0,0 +1,122 @@
import { BookOutlined, DeleteOutlined, DownloadOutlined, PlusOutlined } from '@ant-design/icons'
import { Button, Card, Dropdown, Empty, Popconfirm, Progress, Tag, message } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiDelete, apiGet } from '../api/client'
import type { Project } from '../api/types'
import NewProjectModal from '../components/NewProjectModal'
import SettingsModal from '../components/SettingsModal'
export default function HomePage() {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [newOpen, setNewOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const navigate = useNavigate()
const load = () => {
setLoading(true)
apiGet<Project[]>('/api/projects')
.then(setProjects)
.catch((e) => message.error(e.message))
.finally(() => setLoading(false))
}
useEffect(load, [])
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 26 }}>
<BookOutlined style={{ color: '#7c3aed', marginRight: 10 }} />
智能小说工作台
</h1>
<div style={{ color: '#888', marginTop: 6 }}>从一句话创意到完整长篇:人物 · 世界观 · 大纲 · 细纲 · 正文,全程可控可改</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => setSettingsOpen(true)}>模型设置</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewOpen(true)}>
新建作品
</Button>
</div>
</div>
{loading ? null : projects.length === 0 ? (
<Empty description="还没有作品,点击右上角「新建作品」开始" style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 16 }}>
{projects.map((p) => {
const written = p.written_count ?? 0
const total = p.chapter_count ?? 0
const percent = total > 0 ? Math.round((written / Math.max(total, p.target_chapter_count)) * 100) : 0
return (
<Card
key={p.id}
hoverable
onClick={() => navigate(`/workbench/${p.id}`)}
title={<span style={{ fontSize: 16 }}>{p.name}</span>}
extra={
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 4 }}>
<Dropdown
menu={{
items: [
{ key: 'txt', icon: <DownloadOutlined />, label: '导出 TXT' },
{ key: 'epub', icon: <DownloadOutlined />, label: '导出 EPUB' }
],
onClick: ({ key }) => window.open(`/api/export/${p.id}/${key}`)
}}
>
<Button type="text" size="small" icon={<DownloadOutlined />} />
</Dropdown>
<Popconfirm
title="删除作品"
description="将删除该项目全部数据,确定?"
onConfirm={(e) => {
e?.stopPropagation()
apiDelete(`/api/projects/${p.id}`).then(load)
}}
onCancel={(e) => e?.stopPropagation()}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</span>
}
>
{p.genre && <Tag color="purple">{p.genre}</Tag>}
<div
style={{
color: '#666',
fontSize: 13,
margin: '8px 0 12px',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: 40
}}
>
{p.premise || '(未填写故事创意)'}
</div>
<Progress percent={percent} size="small" format={() => `${written}/${Math.max(total, p.target_chapter_count)} 章`} />
<div style={{ color: '#999', fontSize: 12, marginTop: 8 }}>
总字数 {(p.total_words ?? 0).toLocaleString()} · 每章目标 {p.target_chapter_words} 字 · 更新于 {p.updated_at.slice(0, 10)}
</div>
</Card>
)
})}
</div>
)}
<NewProjectModal
open={newOpen}
onClose={() => setNewOpen(false)}
onCreated={(p) => {
setNewOpen(false)
navigate(`/workbench/${p.id}`)
}}
/>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} onSaved={() => message.success('模型设置已保存')} />
</div>
)
}
+410
View File
@@ -0,0 +1,410 @@
import { ArrowLeftOutlined, EditOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons'
import { Button, Dropdown, Form, Input, InputNumber, Layout, Modal, Segmented, Select, Tag, Tooltip, message } from 'antd'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { apiGet, apiPatch, apiPost, ssePost } from '../api/client'
import type { Bundle, Chapter, GenState, PostprocessResult, Project, RewriteReq } from '../api/types'
import ChapterTree from '../components/ChapterTree'
import ChapterEditor from '../components/ChapterEditor'
import GenerateDrawer from '../components/GenerateDrawer'
import OutlineView from '../components/OutlineView'
import BeatsView from '../components/BeatsView'
import PipelineGuide from '../components/PipelineGuide'
import SettingsModal from '../components/SettingsModal'
import StoryBible from '../components/StoryBible'
export default function WorkbenchPage() {
const { id } = useParams()
const pid = Number(id)
const navigate = useNavigate()
const [messageApi, messageContextHolder] = message.useMessage()
const [bundle, setBundle] = useState<Bundle | null>(null)
const [tab, setTab] = useState<'prose' | 'outline' | 'beats'>('prose')
const [chapterId, setChapterId] = useState<number | null>(null)
const [beatsVolumeId, setBeatsVolumeId] = useState<number | null>(null)
const [gen, setGen] = useState<GenState | null>(null)
const [running, setRunning] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editForm] = Form.useForm()
const abortRef = useRef<AbortController | null>(null)
const acceptRef = useRef<((content: string, data: unknown) => void) | null>(null)
const doneDataRef = useRef<unknown>(null)
// 「先约束后生成」:所有 AI 生成入口先弹要求弹窗(可选),再启动生成
const [reqOpen, setReqOpen] = useState(false)
const [reqTitle, setReqTitle] = useState('')
const [reqText, setReqText] = useState('')
const pendingRunRef = useRef<((requirement: string) => void) | null>(null)
const lastReqRef = useRef<Record<string, string>>({})
const askRequirement = (key: string, title: string, run: (requirement: string) => void) => {
setReqTitle(title)
setReqText(lastReqRef.current[key] ?? '')
pendingRunRef.current = (req) => {
lastReqRef.current[key] = req
run(req)
}
setReqOpen(true)
}
const reload = useCallback(async () => {
const b = await apiGet<Bundle>(`/api/projects/${pid}/bundle`)
setBundle(b)
return b
}, [pid])
useEffect(() => {
reload().catch((e) => messageApi.error(e.message))
}, [pid, reload, messageApi])
// 默认选中第一章
useEffect(() => {
if (bundle && chapterId === null && bundle.chapters.length > 0) setChapterId(bundle.chapters[0].id)
}, [bundle, chapterId])
const updateChapterLocal = useCallback((ch: Partial<Bundle['chapters'][number]> & { id: number }) => {
setBundle((b) => (b ? { ...b, chapters: b.chapters.map((c) => (c.id === ch.id ? { ...c, ...ch } : c)) } : b))
}, [])
// ---------- 生成编排 ----------
const runGenerate = (stage: string, label: string, payload: object, onAccept: (content: string, data: unknown) => void) => {
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
acceptRef.current = onAccept
doneDataRef.current = null
setGen({ stage, label, text: '', done: false })
setRunning(true)
ssePost(`/api/generate/${stage}`, payload, {
signal: ac.signal,
onDelta: (t) => setGen((g) => (g ? { ...g, text: g.text + t } : g)),
onDone: (m) => {
doneDataRef.current = m.data ?? null
setGen((g) => (g ? { ...g, done: true, text: m.content ?? g.text } : g))
},
onError: (msg) => messageApi.error(msg)
}).catch((e) => {
if ((e as Error).name !== 'AbortError') messageApi.error(String(e))
}).finally(() => setRunning(false))
}
const acceptGenerate = () => {
if (!gen) return
const { text } = gen
const handler = acceptRef.current
const data = doneDataRef.current
setGen(null)
Promise.resolve()
.then(() => handler?.(text, data))
.catch((e) => messageApi.error('采纳失败:' + (e as Error).message))
}
// 各阶段的采纳回调
const runGenerateCharacters = (requirement: string) =>
runGenerate('characters', '生成人物卡', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/characters', { projectId: pid, items: data })
messageApi.success(`已保存 ${r.inserted} 个人物卡,可在右侧「人物」中查看修改`)
await reload()
})
const runGenerateWorld = (requirement: string) =>
runGenerate('world', '生成世界观设定', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/world', { projectId: pid, items: data })
messageApi.success(`已保存 ${r.inserted} 条世界观设定`)
await reload()
})
const runGenerateVolumes = (requirement: string) => {
const hasContent = (bundle?.chapters ?? []).some((c) => c.content.trim())
const doRun = () =>
runGenerate('volumes', '生成分卷大纲', { projectId: pid, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/volumes', { projectId: pid, items: data })
messageApi.success(`大纲已应用:${r.inserted} 卷。下一步请到「细纲」为每卷生成分章细纲`)
await reload()
})
if (hasContent || (bundle?.beats.length ?? 0) > 0) {
Modal.confirm({
title: '重新生成大纲将清空现有卷与细纲',
content: '已写正文的章节会保留为「未分卷」;空章节与细纲会被删除。确定继续?',
okText: '继续生成',
okButtonProps: { danger: true },
onOk: doRun
})
} else doRun()
}
const runGenerateBeats = (volumeId: number, requirement: string) => {
const volume = bundle?.volumes.find((v) => v.id === volumeId)
const doRun = () =>
runGenerate('beats', `生成《${volume?.title ?? ''}》分章细纲`, { projectId: pid, volumeId, requirement }, async (_c, data) => {
const r = await apiPost<{ inserted: number }>('/api/generate/apply/beats', { projectId: pid, volumeId, items: data })
messageApi.success(`细纲已应用:${r.inserted} 章,章节树已更新。可在「细纲」中微调后开始写正文`)
await reload()
})
const volChapters = bundle?.chapters.filter((c) => c.volume_id === volumeId) ?? []
if (volChapters.some((c) => c.content.trim())) {
Modal.confirm({
title: '重新生成细纲将删除本卷空章节',
content: '已写正文的章节会保留,但不再关联新细纲。确定继续?',
okText: '继续生成',
okButtonProps: { danger: true },
onOk: doRun
})
} else doRun()
}
const runProse = (chId: number, requirement: string) =>
runGenerate('prose', '生成本章正文', { projectId: pid, chapterId: chId, requirement }, async (content) => {
if (!content.trim()) {
messageApi.warning('生成为空')
return
}
await apiPatch(`/api/chapters/${chId}`, { content, status: 'generated' })
updateChapterLocal({ id: chId, content, status: 'generated' })
messageApi.open({ key: 'pp', type: 'loading', content: 'AI 正在更新故事圣经(摘要 / 设定 / 伏笔 / 时间线)…', duration: 0 })
try {
const r = await apiPost<PostprocessResult>(`/api/chapters/${chId}/postprocess`)
messageApi.open({
key: 'pp',
type: 'success',
content: `故事圣经已更新:新设定 +${r.new_world_facts},人物变化 +${r.character_updates},伏笔 +${r.new_foreshadows} / 回收 ${r.paid_foreshadows},时间线 +${r.timeline_events}`
})
} catch (e) {
messageApi.open({ key: 'pp', type: 'error', content: '圣经回写失败:' + (e as Error).message })
}
await reload()
})
const runRewrite = (req: RewriteReq) =>
runGenerate('rewrite', { rewrite: '重写段落', expand: '扩写段落', shrink: '缩写段落', custom: '按指令改写' }[req.mode], req, async (text) => {
const ch = await apiGet<Bundle['chapters'][number]>(`/api/chapters/${req.chapterId}`)
const next = ch.content.slice(0, req.start) + text + ch.content.slice(req.end)
await apiPatch(`/api/chapters/${req.chapterId}`, { content: next, status: 'edited' })
updateChapterLocal({ id: req.chapterId, content: next, status: 'edited' })
messageApi.success('段落已替换')
})
// ---------- 渲染 ----------
if (!bundle) {
return (
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
<div style={{ color: '#999' }}>加载中…</div>
</div>
)
}
const project = bundle.project
const totalWords = bundle.chapters.reduce((s, c) => s + c.word_count, 0)
const writtenCount = bundle.chapters.filter((c) => c.content.trim()).length
const chapter = bundle.chapters.find((c) => c.id === chapterId)
return (
<Layout style={{ height: '100vh' }}>
{messageContextHolder}
<Layout.Header
style={{ background: '#fff', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', gap: 12, paddingInline: 20, height: 56, lineHeight: '56px' }}
>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
<span style={{ fontSize: 16, fontWeight: 600 }}>{project.name}</span>
{project.genre && <Tag color="purple">{project.genre}</Tag>}
<Tooltip title="编辑作品信息">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => { editForm.setFieldsValue(project); setEditOpen(true) }} />
</Tooltip>
<span style={{ color: '#999', fontSize: 13, marginLeft: 8 }}>
总字数 {totalWords.toLocaleString()} · 已写 {writtenCount}/{Math.max(bundle.chapters.length, project.target_chapter_count)} 章
</span>
<div style={{ flex: 1 }} />
<Dropdown
menu={{
items: [
{ key: 'txt', label: '导出 TXT' },
{ key: 'epub', label: '导出 EPUB' }
],
onClick: ({ key }) => window.open(`/api/export/${pid}/${key}`)
}}
>
<Button icon={<DownloadOutlined />}>导出</Button>
</Dropdown>
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>
模型设置
</Button>
</Layout.Header>
<PipelineGuide
bundle={bundle}
running={running}
onGoto={(t) => setTab(t)}
onGenerateCharacters={() => askRequirement('characters', '生成人物卡 · 生成要求(可选)', runGenerateCharacters)}
onGenerateWorld={() => askRequirement('world', '生成世界观 · 生成要求(可选)', runGenerateWorld)}
/>
<Layout style={{ minHeight: 0 }}>
<Layout.Sider width={300} theme="light" style={{ borderRight: '1px solid #eee', overflow: 'auto' }}>
<ChapterTree
bundle={bundle}
chapterId={chapterId}
running={running}
onSelectChapter={(cid) => {
setChapterId(cid)
setTab('prose')
}}
onSelectVolume={(vid) => {
setBeatsVolumeId(vid)
setTab('beats')
}}
onChanged={() => reload()}
/>
</Layout.Sider>
<Layout.Content style={{ padding: '16px 20px 28px', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
<Segmented
block
style={{ alignSelf: 'flex-start', minWidth: 380 }}
value={tab}
onChange={(v) => setTab(v as typeof tab)}
options={[
{ label: '正文', value: 'prose' },
{ label: '大纲', value: 'outline' },
{ label: '细纲', value: 'beats' }
]}
/>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', marginTop: 12 }}>
{tab === 'prose' && (
<ChapterEditor
bundle={bundle}
chapter={chapter}
running={running}
onPatchChapter={(patch) => apiPatch<Chapter>(`/api/chapters/${patch.id}`, patch).then((c) => updateChapterLocal(c))}
onGenerateProse={(chId) => askRequirement('prose', '生成本章正文 · 生成要求(可选)', (req) => runProse(chId, req))}
onRewrite={runRewrite}
onSwitchChapter={(cid) => setChapterId(cid)}
/>
)}
{tab === 'outline' && (
<OutlineView bundle={bundle} running={running} onGenerate={() => askRequirement('volumes', '生成分卷大纲 · 生成要求(可选)', runGenerateVolumes)} onChanged={() => reload()} />
)}
{tab === 'beats' && (
<BeatsView
bundle={bundle}
volumeId={beatsVolumeId}
setVolumeId={setBeatsVolumeId}
running={running}
onGenerate={(vid) => askRequirement('beats', '生成分章细纲 · 生成要求(可选)', (req) => runGenerateBeats(vid, req))}
onChanged={() => reload()}
onWriteChapter={(cid) => {
setChapterId(cid)
setTab('prose')
}}
/>
)}
</div>
</Layout.Content>
<Layout.Sider width={380} theme="light" style={{ borderLeft: '1px solid #eee', overflow: 'auto' }}>
<div style={{ padding: '6px 12px 16px' }}>
<StoryBible bundle={bundle} onChanged={() => reload()} running={running} />
</div>
</Layout.Sider>
</Layout>
<GenerateDrawer
gen={gen}
running={running}
onAccept={acceptGenerate}
onDiscard={() => setGen(null)}
onAbort={() => abortRef.current?.abort()}
/>
<Modal
title={reqTitle}
open={reqOpen}
onCancel={() => setReqOpen(false)}
onOk={() => {
setReqOpen(false)
pendingRunRef.current?.(reqText.trim())
}}
okText="开始生成"
okButtonProps={{ disabled: running }}
width={540}
>
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>
可选:向 AI 下达本次生成的要求与限制(数量、情节走向、禁用元素、文风偏好等),它会叠加在项目信息之上严格执行;留空则按默认策略生成。生成后请逐项审核,不满意可带着新要求重新生成。
</div>
<div style={{ paddingBottom: 26 }}>
<Input.TextArea
rows={4}
value={reqText}
onChange={(e) => setReqText(e.target.value)}
maxLength={3000}
showCount
placeholder="例:主角性格更内敛;只要都市异能不要修仙元素;反派必须有值得同情的动机……"
/>
</div>
</Modal>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<Modal
title="编辑作品信息"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={async () => {
try {
const values = await editForm.validateFields()
await apiPatch(`/api/projects/${pid}`, values)
setEditOpen(false)
await reload()
} catch (e) {
if ((e as { errorFields?: unknown }).errorFields === undefined) messageApi.error((e as Error).message)
throw e
}
}}
width={640}
>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="作品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="genre" label="题材">
<Select
allowClear
options={['玄幻', '仙侠', '都市', '科幻', '悬疑', '历史', '言情', '奇幻', '武侠', '游戏', '其他'].map((g) => ({ value: g, label: g }))}
/>
</Form.Item>
<Form.Item name="premise" label="故事创意">
<Input.TextArea rows={5} />
</Form.Item>
<Form.Item name="style_notes" label="文风要求">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="style_sample" label="文风示例">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="narrative_pov" label="叙事视角(正文生成时严格执行视角纪律)">
<Select
allowClear
placeholder="选择或留空由 AI 酌情处理"
options={['第三人称限知', '第三人称全知', '第一人称', '多视角轮转'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="taboos" label="禁用内容(严禁出现的元素 / 题材 / 桥段)">
<Input.TextArea rows={2} placeholder="例:不要重生穿越;不要系统流;不出现真实政治人物……" />
</Form.Item>
<Form.Item label="目标规模">
<Input.Group compact>
<Form.Item name="target_chapter_count" noStyle>
<InputNumber min={1} max={2000} addonAfter="章" style={{ width: 160 }} />
</Form.Item>
<Form.Item name="target_chapter_words" noStyle>
<InputNumber min={200} max={20000} addonAfter="字/章" style={{ width: 180, marginLeft: 8 }} />
</Form.Item>
</Input.Group>
</Form.Item>
</Form>
</Modal>
</Layout>
)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:5175'
}
}
})