Skip to content

AI 辅助小说写作工作流(NovelWorkflow)· 保姆级项目详解 ​

这是本站「项目详解」系列的一篇。目标:读完你能完全看懂这个项目的每一处设计,并自己动手改。 面向:会一点 JS/TS、想学"AI 工具怎么落地"的人。零基础也能看懂思路。


1. 它是干什么的(以及它不是什么) ​

一句话:人来写大纲和每章的章纲,AI 按你的设定和已写章节,把指定章节的正文补全。

它不是"一句话生成一部小说"的玩具。它解决的是一个很具体的问题:

你脑子里有故事、有设定、有每一章的梗概,但把梗概扩写成几千字正文很累、很耗时。把这件"体力活"交给 AI,创意的控制权仍在你手里。

设计上有三条铁律,贯穿整个代码:

  1. 数据归你:所有内容都是磁盘上的纯文本(.txt),用记事本就能改,也能用 Git 管理。
  2. AI 只在生成时介入:它绝不偷偷改你的文件;一致性检查报告也是以注释形式追加,供你参考。
  3. 不绑厂商:它不直连任何模型 API,而是调用本机已装好的 opencode CLI,因此"免费模型开箱即用",也能用你自己配的 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

一次生成的完整数据流:

  1. index.ts 解析命令行 → 找到对应命令函数
  2. agents.generateChapter() 调 prompts.buildChapterPrompt() 装配提示词
  3. prompts.ts 从磁盘读取:设定、时间线、人物、文风、最近 N 章、本章章纲
  4. ai.aiCall() spawn 出 opencode run,把 prompt 从 stdin 喂进去
  5. 解析其 --format json 的输出事件,抽出正文
  6. 按 第 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章"标题……
- 严禁调用任何工具、严禁创建或修改任何文件、严禁使用代码块……
- 严格遵循设定与人物角色,风格与最近章节保持一致……

几个值得学的设计:

  1. <novel>…</novel> 标签:让模型把正文放进标签,程序用正则抽取,能容忍前后多余的废话。
  2. "严禁调用任何工具":因为底层是 opencode run,它本身是个能读写文件的 agent;不约束的话,模型可能去动文件。这里用提示词把它"按"成纯文本生成器。
  3. 最近 N 章:contextLength(默认 5)控制"承接前文"的窗口大小——太长费 token,太短接不上。
  4. 字数要求是条件注入:只有传了 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 ​

结构很直白:

  1. printHelp():打印手册。
  2. parseGlobalArgs(argv):手动扫一遍参数(-d/--dir、-m/--model、--check、--len…),不认识的非选项参数收进 rest。
  3. 各 cmdXxx() 函数实现具体命令。
  4. 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/generateSSE 流式生成
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. 如何自己扩展(练手建议) ​

  1. 加一个 CLI 命令:在 index.ts 写 cmdXxx(),在 switch 里加 case,在 printHelp() 里补一行说明。
  2. 改生成质量:调 buildChapterPrompt 的措辞/顺序;或加一类"知识库文件"(比如"势力设定")到 loadAllContext。
  3. 加一种导出:仿 mergeNovel 写 exportEpub,读章节后生成对应格式。
  4. 加"人物出场范围":给人物 frontmatter 加 until: N,在 loadAllContext 里根据当前章号过滤(需要把章号传进去)。
  5. 加"超标自动拆分章节":生成后若 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. 结语:这个项目最值得学的三件事 ​

  1. 把"人和 AI 的分工"写进架构:人管结构与设定,AI 管扩写——而不是让 AI 端到端。
  2. 把上下文当产品来设计:buildChapterPrompt 的每一段(设定/人物/文风/前文/章纲/特别要求)都是在"喂对了料"。
  3. 数据主权优先:纯文本 + 本地存储,让工具随时可被替换,你的作品永远是你的。

想继续深入:读 src/prompts.ts 和 src/agents.ts 这两个文件就抓住了主干;storage.ts 是地基;ai.ts 是"接模型"的通用范式,可以搬到别的项目里。