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