Appearance
AI 辅助小说写作工作流(NovelWorkflow)· 保姆级项目详解
这是本站「项目详解」系列的一篇。目标:读完你能完全看懂这个项目的每一处设计,并自己动手改。 面向:会一点 JS/TS、想学"AI 工具怎么落地"的人。零基础也能看懂思路。
1. 它是干什么的(以及它不是什么)
一句话:人来写大纲和每章的章纲,AI 按你的设定和已写章节,把指定章节的正文补全。
它不是"一句话生成一部小说"的玩具。它解决的是一个很具体的问题:
你脑子里有故事、有设定、有每一章的梗概,但把梗概扩写成几千字正文很累、很耗时。把这件"体力活"交给 AI,创意的控制权仍在你手里。
设计上有三条铁律,贯穿整个代码:
- 数据归你:所有内容都是磁盘上的纯文本(
.txt),用记事本就能改,也能用 Git 管理。 - AI 只在生成时介入:它绝不偷偷改你的文件;一致性检查报告也是以注释形式追加,供你参考。
- 不绑厂商:它不直连任何模型 API,而是调用本机已装好的
opencodeCLI,因此"免费模型开箱即用",也能用你自己配的 API。
2. 技术选型与为什么
| 选择 | 原因 |
|---|---|
| Node.js + TypeScript(ESM) | 单文件即可跨平台运行;TS 提供类型安全;ESM 是现代模块标准 |
| 调用 opencode CLI 而非直连 API | 免去管 key、免去对接各家 SDK;opencode 里配好的模型直接可用 |
纯 .txt + frontmatter | 无数据库、无锁定;人物/章节的元信息用极简 YAML frontmatter |
| 自绘 TUI(不依赖 Ink/React) | 终端里也能有鼠标交互;避免引入重型 UI 框架 |
| 网页版(原生 HTML/JS) | 零构建、零框架,server.ts 直接吐 index.html |
3. 整体架构与数据流
┌──────────── CLI (index.ts) ────────────┐
│ novel new / list / generate / merge … │
└───────────────┬─────────────────────────┘
│
┌───────────────▼──────────────┐
│ agents.ts (业务编排) │
│ generateChapter / mergeNovel │
└───────┬───────────────┬───────┘
│ │
┌────────────▼───┐ ┌───────▼────────┐
│ prompts.ts │ │ storage.ts │
│ 装配 prompt、 │ │ 项目/文件抽象、 │
│ 读上下文 │ │ frontmatter、 │
└────────┬───────┘ │ .txt 迁移 │
│ └────────────────┘
┌────────▼───────┐
│ ai.ts │ spawn: opencode run -m <模型> --format json
│ 调用 opencode │ ← 解析流式 JSON 事件,取正文
└────────┬───────┘
│
写回 chapters/NNN.txt一次生成的完整数据流:
index.ts解析命令行 → 找到对应命令函数agents.generateChapter()调prompts.buildChapterPrompt()装配提示词prompts.ts从磁盘读取:设定、时间线、人物、文风、最近 N 章、本章章纲ai.aiCall()spawn 出opencode run,把 prompt 从 stdin 喂进去- 解析其
--format json的输出事件,抽出正文 - 按
第 N 章 标题 + 正文的格式写回chapters/NNN.txt
4. 数据与存储层:src/storage.ts
这是整个项目的"地基"。核心是一个类 NovelProject,它把一个小说文件夹抽象成对象。
4.1 NovelProject
ts
export class NovelProject {
readonly root: string; // 这本小说的根目录
readonly outDir: string; // 父目录(所有小说所在的目录)
static async init(outDir, name, model) { … } // 建新项目
static async load(outDir, name) { … } // 打开已有项目(会触发迁移)
outlinePath() / settingsPath() / stylePath() / timelinePath() // 都是 .txt
charactersDir() / chaptersDir()
}init() 会建好 characters/、chapters/ 两个子目录,并写入 project.json。 load() 会检查 project.json 是否存在,不存在就报"项目不存在",存在则先跑一次迁移:
ts
static async load(outDir, name) {
const root = path.join(outDir, sanitize(name));
if (!(await exists(path.join(root, "project.json")))) throw new Error(…);
const project = new NovelProject(root);
await project.migrateLegacy(); // ← 关键
return project;
}为什么要迁移? 项目早期用 .md 存文件,后来为了"投稿直接可用"改成了 .txt。migrateLegacy() 负责把老项目的 .md 改名成 .txt:
outline.md/settings.md/timeline.md/style.md→ 直接改名characters/*.md→ 改名chapters/NNN.md→ 不只是改名:还要把 frontmatter 去掉,把# 第 N 章 标题变成纯文本第 N 章 标题,再写到NNN.txt并删掉旧文件
ts
const raw = await fs.readFile(src, "utf-8");
const { data, body } = parseFrontmatter(raw); // 拆出元信息与正文
const title = (data.title as string) || "";
const stripped = stripChapterHeading(body); // 去掉 "# 第 N 章 标题"
const head = `第 ${num} 章${title ? ` ${title}` : ""}`;
await fs.writeFile(dst, stripped ? `${head}\n\n${stripped}` : head, "utf-8");
await fs.rm(src, { force: true }); // 删旧 .md(按需求不留备份)4.2 frontmatter 解析(迷你 YAML)
人物、章节的元信息写在文件开头的 --- 之间。项目不引入 YAML 库,而是写了个极简解析器:
ts
export function parseFrontmatter(raw: string) {
const data: Record<string, unknown> = {};
if (!raw.startsWith("---")) return { data, body: raw }; // 没有 frontmatter 就当全文是正文
const end = raw.indexOf("\n---", 4);
if (end < 0) return { data, body: raw };
const fm = raw.slice(4, end);
const body = raw.slice(end + 4).replace(/^\n/, "");
for (const line of fm.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
if (!m) continue;
const key = m[1], val = m[2].trim();
if (key === "aliases" || key === "tags") { // [a, b, c] → 数组
data[key] = val.replace(/^\[/, "").replace(/\]$/, "").split(",").map(s => s.trim()).filter(Boolean);
} else {
data[key] = val; // 其它当字符串
}
}
return { data, body };
}设计取舍:只支持
key: value和方括号数组,够用且零依赖。它不处理嵌套、多行值、引号——因为本项目的元信息本来就很简单。
4.3 从大纲里切章:extractHeadings
大纲用 ## 标题 分章。extractHeadings 把大纲切成 {heading, content}[]:
ts
for (const line of lines) {
const h = line.match(/^##\s+(.+)$/); // 只认二级标题 ##(### 不会误切)
if (h) { push 上一节; cur = { heading: h[1], content: [] }; }
else if (cur) cur.content.push(line);
}关键点:### 写作提示 是三级标题,不会被这里切开,所以它会留在该章的 content 里——后面再单独拆出来(见 5.4)。
4.4 去掉章节标题行:stripChapterHeading
ts
export function stripChapterHeading(body: string): string {
return body.replace(/^\s*#?\s*第\s*[0-9一二三四五六七八九十百零两]+\s*章[^\n]*\n*/, "").trim();
}用途:合并投稿时,避免"标题"重复出现;也用于章节正文读取。它同时支持阿拉伯数字和中文数字(第一到第二百零两)。
5. 提示词装配:src/prompts.ts(本项目的灵魂)
AI 生成得好不好,八成取决于这里给它的上下文。
5.1 ChapterBrief:一章的"简报"
ts
export interface ChapterBrief {
chapter: number;
title: string;
outline: string; // 章纲
prompt?: string; // ### 写作提示(可选,最高优先级)
}5.2 loadAllContext:把"世界观知识库"拼成一段文本
按顺序拼:设定 → 时间线 → 人物 → 文风。其中两个细节值得学:
- 人物过滤:
characterDisabled(tags)检查 tags 是否含"停用/隐藏",含则跳过。这就是"只出场一两次的配角不再被读"的实现。 - 别称注入:人物的
aliases会拼成"(别称:叶兄、尘儿)",帮助模型认人。
ts
const chars = (await loadCharacters(p)).filter(c => !characterDisabled(c.meta.tags));- 文风处理:
style.txt会去掉所有标题行,再包上"必须严格遵守"的强约束:
ts
const body = style.split(/\r?\n/).filter(l => !/^#+\s/.test(l)).join("\n").trim();
if (body) parts.push("# 语言风格规范(必须严格遵守)\n在撰写正文时,务必逐条遵循以下文风要求,违者视为不合格:\n" + body);5.3 buildChapterPrompt:真正喂给模型的字符串
顺序非常讲究:
你是专业的小说作者。请根据下面的大纲、设定和最近的已写章节,补全指定的章节正文。
<# 世界观设定 / # 时间线 / # 人物设定 / # 语言风格规范> ← loadAllContext()
# 目标章节
第 N 章:标题
# 章纲
<outline>
<# 本章特别要求> ← promptNote,来自 ### 写作提示
# 最近已写章节(用于衔接风格与情节)
<最近 contextLength 章的正文>
# 要求
<字数要求,可选>
- 把本章正文完整地放在 <novel> 和 </novel> 之间输出……
- 正文必须是叙事文字,直接以正文内容开始,不要写"第X章"标题……
- 严禁调用任何工具、严禁创建或修改任何文件、严禁使用代码块……
- 严格遵循设定与人物角色,风格与最近章节保持一致……几个值得学的设计:
<novel>…</novel>标签:让模型把正文放进标签,程序用正则抽取,能容忍前后多余的废话。- "严禁调用任何工具":因为底层是
opencode run,它本身是个能读写文件的 agent;不约束的话,模型可能去动文件。这里用提示词把它"按"成纯文本生成器。 - 最近 N 章:
contextLength(默认 5)控制"承接前文"的窗口大小——太长费 token,太短接不上。 - 字数要求是条件注入:只有传了
targetWords才加那句话,否则一个字都不提。
5.4 拆出"每章提示词":splitChapterPrompt
ts
const PROMPT_HEADING = /^#{0,4}\s*(写作提示|提示词|生成提示|本章提示|本章要求|提示)\s*[::]?\s*$/;
export function splitChapterPrompt(content) {
const lines = content.split(/\r?\n/);
const idx = lines.findIndex(l => PROMPT_HEADING.test(l.trim()));
if (idx < 0) return { outline: content.trim() };
return {
outline: lines.slice(0, idx).join("\n").trim(), // 标记之前 = 章纲
prompt: lines.slice(idx + 1).join("\n").trim(), // 标记之后 = 特别要求
};
}于是大纲里可以这样写:
markdown
## 第三章 初窥灵界
(这里写本章要发生什么……)
### 写作提示
- 用第一人称,结尾留悬念loadOutlineChapters() 会把每个 ## 段 变成 ChapterBrief,并顺便把 ### 写作提示 拆出来。
5.5 一致性检查:buildConsistencyCheckPrompt
正文生成后,再让模型"以审校编辑身份"对照设定和前文检查,输出三类内容:问题清单 / 冲突点 / 改善建议。结果由 agents.ts 以 <!-- 一致性检查报告 --> 注释追加在章节末尾。
6. 业务编排:src/agents.ts
6.1 generateChapter(单章)
ts
const brief = briefs.find(b => b.chapter === opts.chapter);
if (!brief) throw new Error(`大纲中没有第 ${opts.chapter} 章`);
const prompt = await buildChapterPrompt(p, brief, { drafts, contextLength, targetWords });
const cfg = await p.config();
const res = await aiCall({ model: cfg.model, prompt }); // ← 调模型
const clean = extractNovel(res.text); // 抽 <novel> 内容
let final = clean;
if (opts.checkConsistency) { … final = clean + "\n\n<!-- 一致性检查报告 -->\n" + report; }
const file = path.join(p.chaptersDir(), `${pad(opts.chapter)}.txt`);
await fs.writeFile(file, `第 ${opts.chapter} 章 ${brief.title}\n\n${final}`, "utf-8");注意:章号补零成 3 位(001.txt),这样文件按名字排序就是章节顺序。
6.2 generateRange(范围/全部)
算出要生成的章节集合,循环调用单章生成,并统计每章字数。--all 会先排除"已写"的章。
6.3 countWords(中文字数)
ts
t = t.replace(/<!--[\s\S]*?-->/g, "").replace(/^\s*#.*$/gm, ""); // 去掉注释行、标题行
return (t.match(/[\u4e00-\u9fa5]|[\u4e00-\u9fff]/g) || []).length; // 只数汉字为什么只数汉字?因为目标是中文网文投稿;英文/数字不算"字数"更贴近网站口径。
6.4 mergeNovel(合并投稿)
把选中的章节合成一份新文件,源文件不动:
- 文件名默认
<书>_合并_起-止.txt - 开头生成"目录"
- 每章:先写
第 N 章 标题,再写"去掉标题行后的正文" - 未写的章标注"(本章尚未撰写)"
7. 模型调用层:src/ai.ts
7.1 找到 opencode 可执行文件
ts
export function resolveOpencode(): string {
if (process.env.OPENCODE_PATH) return process.env.OPENCODE_PATH;
const candidates = [ …/node_modules/opencode-ai/bin/opencode.exe, …/opencode.exe, "opencode" ];
// 逐个 existsSync 检查,最后回退到 PATH 里的 "opencode"
}7.2 一次性调用 aiCall
ts
const child = spawn(resolveOpencode(), ["run", "-m", opts.model, "--format", "json"], { stdio: ["pipe","pipe","pipe"] });
child.stdin.write(opts.prompt); child.stdin.end(); // 把 prompt 从 stdin 喂进去
// 收集 stdout,close 后解析
const parsed = eventStreamFrom(stdout);
return { ok: !parsed.error, text: parsed.text ?? "", error: parsed.error, cost: parsed.cost, tokens: parsed.tokens };7.3 流式调用 aiCallStream
按行解析 stdout(opencode --format json 每行是一个 JSON 事件):
ts
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim();
handleLine(line); // 每读到一行就处理
}
function handleLine(line) {
const evt = JSON.parse(line);
if (evt.type === "error" || evt.error) error = evt.error ?? evt.part?.text;
if (evt.type === "text" && typeof evt.part?.text === "string") { // ← 文本增量
streamed += evt.part.text;
cb.onText?.(evt.part.text); // 回调:网页/终端可实时显示
}
if (evt.type === "step_finish") { input = evt.tokens.input; output = evt.tokens.output; cost = evt.cost; }
}这就是"流式生成"的本质:opencode 把模型的输出切成一个个 text 事件吐出来,我们边收边显示。
8. CLI 入口:src/index.ts
结构很直白:
printHelp():打印手册。parseGlobalArgs(argv):手动扫一遍参数(-d/--dir、-m/--model、--check、--len…),不认识的非选项参数收进rest。- 各
cmdXxx()函数实现具体命令。 main()用switch(cmd)分发。
为什么手写参数解析? 依赖越少越好、行为完全可控(比如 --all 不需要章号但书名是必要位置参数这种特殊规则,用现成库反而别扭)。
9. 本地网页:src/web/server.ts + src/web/index.html
9.1 后端路由(node:http,零框架)
| 方法/路径 | 作用 |
|---|---|
GET / | 返回 index.html |
GET /api/projects | 列出所有小说 |
POST /api/projects | 新建小说 |
GET /api/projects/:name | 详情(章节状态、人物、模型) |
DELETE /api/projects/:name | 删除项目 |
GET/PUT /api/projects/:name/chapters/:n | 读/写某章正文 |
GET/POST/PUT/DELETE /api/projects/:name/characters… | 人物增删改查 |
GET/PUT /api/projects/:name/files/(outline|settings|timeline|style) | 读写大纲/设定等 |
PUT /api/projects/:name/model | 切换模型 |
GET /api/projects/:name/merge(兼容 /export) | 合并并下载 |
POST /api/projects/:name/generate | SSE 流式生成 |
GET /api/models | 列出 opencode 里的模型 |
9.2 SSE 流式生成是怎么实现的
POST /generate 返回 text/event-stream,分三类事件:
ts
send("progress", { msg: "构造上下文…" }); // 进度
send("chunk", { text }); // 正文增量(来自 aiCallStream 的 onText)
send("done", { chapter, file, words }); // 单章完成
send("done_batch", { count }); // 批次完成前端用 fetch + res.body.getReader() 读流,边收边把文字追加到日志区。
小知识:为什么不用
EventSource?因为EventSource只能 GET,而生成请求要带 JSON body,所以用fetch手动读text/event-stream。
9.3 前端界面(index.html)
- 单页、原生 JS,无构建。
- 侧栏列出小说;顶栏标签:章节 / 生成 / 大纲 / 设定 / 时间线 / 人物 / 文风 / 管理。
- 关键交互:生成页有"范围 + 是否限制字数 + 一致性检查";管理页有"模型下拉框 + 合并投稿文本"。
- 改动后必须
npm run build,因为scripts/copy-web.mjs会把src/web/index.html拷到dist/web/。
10. 终端 TUI:src/ui/
screen.ts:进入终端的 raw mode,自己解析按键和鼠标转义序列,维护一个"逐行帧缓冲"再一次性paint()到屏幕(避免闪烁)。tui.ts:在帧缓冲之上画"阅读/编辑/生成"三种界面,支持标签切换、章节/人物选择、滚动、界面内简易编辑器(Ctrl+S保存)。
为什么要自绘? 为了在纯终端里也支持鼠标点击、不引入 React/Ink。代价是输入解析和绘制都要自己写。
11. 如何自己扩展(练手建议)
- 加一个 CLI 命令:在
index.ts写cmdXxx(),在switch里加case,在printHelp()里补一行说明。 - 改生成质量:调
buildChapterPrompt的措辞/顺序;或加一类"知识库文件"(比如"势力设定")到loadAllContext。 - 加一种导出:仿
mergeNovel写exportEpub,读章节后生成对应格式。 - 加"人物出场范围":给人物 frontmatter 加
until: N,在loadAllContext里根据当前章号过滤(需要把章号传进去)。 - 加"超标自动拆分章节":生成后若
countWords超阈值,让 AI 把正文切成两段,分别写入NNN.txt与NNN+1.txt,并顺延后续章号(要小心引用与顺序)。
12. 设计取舍与踩过的坑
- 纯 txt vs Markdown:为了"投稿即用"和"任何编辑器可读",正文用纯 txt;元信息才用 frontmatter。
- 不直连 API:换来"免费模型可用",代价是默认模型名会随 opencode 更新而失效(换模型用
novel set-model)。 - 移动项目后全局命令失效:
npm link建的是软链,目录一挪就断,需重新npm link。 - 忘构建:改了
index.html不npm run build,网页看不到变化。 - 模型会"编":提示词里三令五申"不要调用工具/不要写标题/只输出标签内正文",就是为了压住大模型的"自作主张"。
13. 结语:这个项目最值得学的三件事
- 把"人和 AI 的分工"写进架构:人管结构与设定,AI 管扩写——而不是让 AI 端到端。
- 把上下文当产品来设计:
buildChapterPrompt的每一段(设定/人物/文风/前文/章纲/特别要求)都是在"喂对了料"。 - 数据主权优先:纯文本 + 本地存储,让工具随时可被替换,你的作品永远是你的。
想继续深入:读
src/prompts.ts和src/agents.ts这两个文件就抓住了主干;storage.ts是地基;ai.ts是"接模型"的通用范式,可以搬到别的项目里。