← dsh-novel-writer 技术文档 / novel_draft

novel_draft

实时聊天写作的草稿镜像:AI 一段段地写,每写完一段就把这一段正文交给本工具,由程序追加进独立目录 .novel-writer/drafts/<书>/ 并自行累计字数;只追加,AI 不读、不改、不清;正式章节文件在用户确认之前一个字节都不动。它不生成正文,也不判断「该不该问人」——问不问、什么时候问,由程序按字数阈值决定,工具只把结论放在 nextAction 里(continue / ask_user)。

为什么必须另起一个目录

这是整个功能的设计核心,也是唯一一处「放错位置就会静默把整本书的数字搞坏」的地方。

插件所有章节扫描都按 <书库根>/novels/<书>/ 下的文件枚举(core.scanChapters),并把每个 .md 当成一章参与统计:章节数、总字数、六维基线、语义索引、风格自检、关键词、连贯性审计……全部从这份枚举派生。只要把「正在写的半成品」写进那个目录,这些数字就会被草稿污染——同一章出现两份、章号撞号、字数虚增、基线漂移、语义索引里混入还没定稿的段落。

更麻烦的是它不会报错:界面上只表现为「数字怪怪的」「同一章好像有两份」「基线忽然变了」,用户很难据此自查,模型也无从判断。

因此草稿镜像固定落在数据目录里,与正文目录同级但不相交:

镜像目录(DRAFT_DIRNAME = "drafts") <书库根>/.novel-writer/drafts/<书>/ ├─ <章键>__draft.md ← 逐段正文(DRAFT_FILE_SUFFIX) └─ <章键>__state.json ← 程序账本(DRAFT_STATE_SUFFIX) 正文目录(绝不允许写入) <书库根>/novels/<书>/<第NN章>.md

工具层有一道硬断言:每次计算镜像路径后都跑一次 isInsideBookDir(root, book, target),一旦路径落进正文目录就直接拒绝执行:

assert(!isInsideBookDir(root, book, file), "草稿镜像不能放在正文目录 novels/" + book + "/ 内——半成品会被章节扫描当成正文,污染字数与基线")

isInsideBookDir 比较的是 resolve() 之后的绝对路径,并按目录边界判定(b === a 或 b 以 a + 路径分隔符 开头),因此 …/novels/雨夜灯甲/x 不会被误判成 …/novels/雨夜灯 内部。解析失败(拿不到可用绝对路径)时返回 false,即「不敢断言在内部」——真正拦人的是路径本身由 draftBookDir 拼出,永远落在 .novel-writer/drafts/ 下。

同一个断言在第二处也跑。novel_new_chapter 的「定稿落盘」钩子在写镜像前重复一次同样的校验(见下文「与其它工具的关系」)。两处都写,是因为这条约束一旦破掉,损失是全书数据被污染,不是一次调用失败。

参数

参数类型必填说明
actionstring是枚举只有一个值 commit(提交这一段正文)。传 open / read / clear / append 等任何其它值都抛「草稿镜像只支持 action=commit(只追加)。它由程序维护、不交给 AI 读改清——需要清理请人工删除 <书库>/.novel-writer/drafts/ 下的文件」。
bookstring是书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤后同时用于拼镜像目录。
chapterstring否章标识(章号 / 第NN章 / 标题),只用来命名镜像文件与账本,不改变写入内容。省略或纯空白 → 一并按「未命名章」处理(draftKeyFor 的兜底也是同一个词)。
textstring是只传这一段的正文,不是整章。经 requiredString 取用,因此首尾空白会被 trim() 掉,纯空白视为未提供并报「参数 "text" 不能为空字符串」。重复段落也照写,程序不去重。
rootstring否章节库根目录。

契约口径:required: ["action", "book", "text"],additionalProperties: false;参数表里没有 read / clear / index——「只追加」不是文档承诺,而是接口面上不存在修改某一段的能力(test/chat-writing-test.mjs 用断言钉住这一条)。

输出结构

契约必返字段(output.schema.required,共 13 个):

["action", "book", "chapter", "mirrorDir", "draftFile", "segmentIndex", "segmentChars", "pendingChars", "threshold", "remaining", "prompts", "nextAction", "message"]
字段类型内容
actionstring恒为 commit(归一后的动作)
bookstring净化后的书名
chapterstring章键(镜像文件名前缀,如 第03章 / 未命名章),不是入参原文;入参写 "3"、"第03章"、"第03章 证词里的空白" 都回 第03章
mirrorDirstring镜像目录绝对路径(…/.novel-writer/drafts/<书>),给人看
draftFilestring该章镜像文件绝对路径(…/<章键>__draft.md),给人看
segmentIndexinteger本次写入是第几段(从 1 起;由账本续号,账本丢失时按镜像段头重建)
segmentCharsinteger本段字数(text.trim() 之后的 JS 字符串长度)
pendingCharsinteger本批已累计多少字(真实累计值)。达阈值那一批会略超阈值(例如阈值 150、本批 162),因为段落按整段累加、不会截断——这个字段报的就是真实的 162,不是阈值本身
thresholdinteger本次生效的程序阈值(字),见「提问节奏」
remainingintegermax(0, threshold - 本批剩余待确认字数)。批次刚被提问时计数器已清零,所以 ask_user 的返回里它等于 threshold(表示「下一批从头累计」),而不是 0;渲染文本在 ask_user 时改写成「本批 162 字 / 阈值 150 字」,避免读成「162 / 150 还没到」
promptsinteger本章累计提问次数(只在 ask_user 那一次 +1)
nextActionstring枚举 continue(继续写下一段,别去问人) / ask_user(程序判定该问了)
messagestring一句中文结论:落了第几段、待确认多少 / 阈值多少、还差多少,或「已达阈值,现在停下来问用户」
combinedTextstring仅 ask_user 时出现:本批全部段落用空行合并后的正文(贴给用户看的那份)
questionstring仅 ask_user 时出现:原样交给提问工具的整段问题,正文已含在 combinedText 里

ask_user 时 question 的固定文案(程序拼,不由模型改写):

这一段(本批合并 N 段 / 共 M 字,程序阈值 T 字)写得对不对? <combinedText> 对就继续写下一段;哪里不对请直接说,我按你说的改。

渲染层(output.render)给人看的那段文本骨架:

<type>novel-draft</type>
<content>
<message>

镜像文件(给人看的,你不要读):<draftFile>
第 N 段已写入(C 字)|待确认 P/T 字|本章已提问 K 次

nextAction = continue → 程序判定:继续写下一段(还差 R 字到阈值)——不要自己去问用户。
nextAction = ask_user → 程序判定:现在停下来问用户(阈值已到)。
                         【问题】<question>
                         【本批正文】<combinedText>
</content>

提问节奏:阈值与账本

「什么时候停下来问用户」是本版刻意从模型手里拿走的判断:交给模型决定,就等于让「短段该不该合并着问」变成临场发挥,而这正是要消除的东西。

阈值:默认 400,用户可填 100~5000

CHAT_PROMPT_THRESHOLD_CHARS = 400 ← 用户没填时的确定行为 CHAT_THRESHOLD_MIN = 100 CHAT_THRESHOLD_MAX = 5000 normalizeChatThreshold(value): n = Number(value) !Number.isFinite(n) 或 n <= 0 → null(表示"用默认值") 其余 → min(5000, max(100, round(n)))
输入落盘 / 生效
400400
800.4800(round)
50100(夹到下限)
9999995000(夹到上限)
0 / null清回默认 400
"abc" / 非数字忽略该次写入,闸门按默认 400

阈值不是每章一份:它存在状态文件 state.json 的顶层 chatThreshold(面板「实时聊天写作 → 提问节奏」那一行,或 novel_sentence_config 的 chatThreshold 参数),全书生效;读取侧取值口径是 state.chatThreshold ?? 400。

判定:达到或超过即提问

reached = pendingChars >= threshold ← 不是 > ,正好等于也提问 if reached: combinedText = pending.join("\n\n") ← 本批所有段落 question = 固定文案 + combinedText prompts += 1 pending = [] / pendingChars = 0 ← 随即清零,下一批重新累计

阈值只决定「何时问」,不影响落盘:每一段都是立刻 append,哪怕只有 5 个字。所以「短段自动合并」合的是提问,不是写入。

账本:该章一份 __state.json

计数不靠读回镜像正文,而是程序自己的账本 <章键>__state.json,与镜像同目录、同章键:

{
  "segments": 2,
  "pendingChars": 0,
  "totalChars": 425,
  "prompts": 1,
  "pending": [],
  "updatedAt": "2026-11-10T13:39:39.069Z"
}
键含义
segments已写入的段数(= 下一次的 segmentIndex − 1)
pendingChars本批已累计、尚未提问的字数(提问后归零)
totalChars本章累计写入字数(只增,不随提问清零)
prompts本章已提问次数
pending本批各段正文的数组——合并提问要用它拼 combinedText,所以账本里存的是文字而不只是计数(提问后清空)
updatedAt最近一次 commit 的 ISO 8601 时间戳

账本丢了会怎样:读不到或 JSON 损坏时,按镜像里已有的段头重建——只数 ## 第N段 的行数(/^## 第\s*\d+\s*段/gm),不把正文读进上下文,并把 pendingChars / totalChars / prompts 归零。结果是段号能续上(不会从第 1 段重新覆盖),但「本轮待确认字数」从头累计。账本写入走 atomicWriteJson(临时文件 + 原子替换)。

字数怎么数。segmentChars / pendingChars 都是 JS 字符串长度(text.trim().length):中文一字记 1,换行与空格也计 1,emoji 等需要代理对的字符记 2。它不是「汉字数」,也没有走 chapterStats 那套章节字数口径。

章键归一与同章认领

镜像文件名与账本文件名共用同一个章键,保证两者永远配对。章键由 draftKeyFor 产出:

raw = String(chapter ?? "").trim() || "未命名章" ① 能解析出章号(parseChapterNumber)→ "第" + padStart(2,"0") + "章" ② 否则 sanitizeSegment(raw):折叠连续点号、去掉首尾点号 ③ 清洗后为空(".." / "///" / 纯非法字符)→ "章-" + sha1(raw) 前 8 位 ④ 清洗后超过 48 字符 → 前 48 字 + "-" + sha1(raw) 前 6 位
入参 chapter章键说明
3第03章能解析出章号就统一成规范写法
第03章 证词里的空白第03章标题被丢掉:同章只能有一个镜像
第2章.md第02章后缀不影响
省略 / 空串未命名章兜底名,同一本书的「未命名」会共用一个文件
a/b/cabc路径分隔符被清掉(不会越界)
***章-36c3eaa0清洗后为空 → 稳定短哈希
"a" × 300aaaa…aaaa-003ef1截到 48 + 6 位哈希(Windows 260 字符路径上限)
为什么一定要归一。真实数据踩过:AI 提交时写 chapter:"3" 得到 3__draft.md,而建章时用文件名 第03章 证词里的空白 又得到另一个文件——同一章两份镜像,用户看到的那份恰好只有「定稿」,于是以为「镜像没多写」。现在只要能解析出章号就统一成 第NN章。

归一之外还有第二层兼容:resolveDraftTarget 在规范文件不存在时,会扫一遍该书的 __draft.md,认领章号相同的第一份既有文件(按文件名排序)作为本章镜像与账本:

key = draftKeyFor(chapter) 规范文件存在 → 用它 否则若入参能解析出章号: 列出目录下所有 *__draft.md,按名排序 第一个 parseChapterNumber(文件名去后缀) === 章号 的 → 认领它(账本也随之改指 <它的键>__state.json) 否则 → 规范文件(首次写入时创建)

这一层是为了兼容早期按字面量命名的镜像(3__draft.md):不认领的话,升级后同一章会再多出一个 第03章__draft.md,历史内容看起来像丢了。

镜像文件长什么样

正文结构只有逐段块,没有标题头;每段以 ## 第N段 开头,行尾带写入时间戳:

## 第1段 · 2026-11-10 13:39:39

第一小段。

## 第2段 · 2026-11-10 13:39:39

……(这一段正文)

## 定稿落盘 · 第2章 · 2026-11-10 13:39:39

> 本章已写入正式章节:第02章 有篇.md(16 字)。本文件是草稿镜像,只追加、只给人看。
时间戳是 UTC。写盘用的是 new Date().toISOString() 截到秒(YYYY-MM-DD HH:MM:SS,不带时区后缀、也不是本地时间),所以文件里看到的时刻通常比北京时间早 8 小时。它只是给人看的标记,不参与任何判断。
一处已修正的口径不一致(v7.3.0 合并时修掉)。写入格式是 ## 第N段 · <时间戳>,而最初的 parseDraftSegments 正则要求「段」字之后直到行尾只有空白(^##\s*第\s*(\d+)\s*段\s*$)——解析不了自己的写入格式。后果很隐蔽:「数段」是对的(建章统计用的正则 ^## 第\s*\d+\s*段 不要求行尾,所以账本重建正常),但「取正文」永远是 0 段,面板预览会显示成空。现在段头允许可选的 · 时间戳 后缀(也兼容人工手写的纯 ## 第N段),并有回归测试盯着。另外:core.draftMirrorHeader / core.renderDraftMirror 定义了带标题头的渲染格式,但写入路径没有调用它们——镜像里确实没有「# 草稿镜像 · 第NN章」这样的头,只有逐段块。判断镜像内容请直接看文件原文。

纪律共八条,逐条都是为了让模型不做判断:

#约束
1动笔前读一遍开写包(novel_chapter_brief);第一步不是问人,是写
2每写完一个自然段,就把这一段正文交给 novel_draft(action="commit", book, chapter, text)
3然后照 nextAction 做:continue 就继续写、不要去问人;ask_user 才把 question 原样交给提问工具,并把 combinedText 一起贴给用户,然后等他回答
4用户说不对 → 只改他指出的那几段,改好照常继续写、继续 commit;不要整章重写,也不要另起一次提问
5用户说对 → 继续写下一段
6本章全部写完后,才用 novel_new_chapter(或 write)把定稿写进正式章节文件
7正式章节在用户确认前不要提前写;用户没回应时不要自己往下写
8镜像只交给人管:不要读它、不要改它、不要清理它,也不要引用它的内容

同一条约束还会以一句话的形式进开写包的检查清单(CHAT_MODE_CHECKLIST):「实时聊天写作(硬约束):每写完一段就 novel_draft commit 交出去;nextAction=continue 就继续写、=ask_user 才用提问工具问用户;镜像不读不改不清;提问时机由程序的字数阈值判定,不要自己决定」。

「程序判定」这四个字是契约,不是建议。如果模型自己决定什么时候问人,pendingChars 与 prompts 就会与它实际问过的次数脱节;如果模型读了镜像再写,它引用的就是自己刚写下的文本(多一轮噪声)。这两件事都没有运行时可拦的判据——所以它们写进系统提示,并由回归测试断言提示词里必须出现 nextAction / ask_user_question / 「不要读它」等关键字。

与其它工具的关系

开写包(novel_chapter_brief)

chatWriting 开启时,开写包多一个字段 chatWriting(关闭时整个键省略):

chatWriting = { on: true, mirrorDir, // 独立目录绝对路径 draftFile, // 本章镜像绝对路径(由 chapterKey 推出) chapterKey, // 由目标章文件名(去 .md)或 "第N章" 推出 threshold, // 用户配置的阈值,未配置则 400 checklist, // CHAT_MODE_CHECKLIST howTo // 一段操作说明 }

渲染文本里对应分区是【实时聊天写作·逐段确认】,逐行打出镜像文件、镜像目录、「程序阈值:T 字(由程序判定何时提问,不要自己决定)」、howTo,末尾一条硬规矩:「每段写完用 novel_draft 交出去并照 nextAction 执行;用户说不对就只改他指出的那几段;确认前不要写正式章节文件」。同时 plan.checklist 追加 CHAT_MODE_CHECKLIST。

开写包刻意不建目录。它的契约是只读(不写任何文件),所以 chatWriting 只算出路径、不落盘;drafts/<书>/ 由第一次 commit 创建(mkdir(recursive))。同理,镜像路径计算失败只会在 degraded 里记一句「实时聊天写作:草稿镜像路径计算失败(本章不注入该模式)」,不影响其余材料。

定稿落盘(novel_new_chapter)

chatWriting 开启时,novel_new_chapter 在章节写盘成功后由插件自己往该章镜像里追加一条「定稿落盘」记录——这件事 AI 不需要、也不该管:

hasSegments = 镜像里存在 /^## 第\s*\d+\s*段/m 追加内容 = 「## 定稿落盘 · 第N章 · <时间戳>」+ 一行「> 本章已写入正式章节:<文件名>(N 字)。本文件是草稿镜像,只追加、只给人看。」 hasSegments === true → 只追加这条标记 hasSegments === false → 标记 + 本次定稿正文(保证人打开镜像有东西可看)

规则的理由很实在:镜像只追加,如果每次都把整章正文再拷一遍,镜像就会与正文等长、看起来像「镜像没多写」;反之若 AI 根本没走 commit(刚开开关),镜像里一个字都没有,人也无从判断。

镜像写入失败不影响建章结果——只记一条 silent_skip 事件(「定稿镜像写入失败(本章照常保存)」),章照常保存、返回体照常。

不受影响的部分

  • 章节扫描 / 摘要 / 伏笔 / 设定 / 语义索引:镜像不在 novels/ 下,scanChapters 看不到它,所以章节数、字数、六维基线、语义索引与关键词统计都与它无关(这正是另起目录的目的)。
  • 工具开关:novel_draft 与其它工具一样受 tools 白名单与总开关约束,execute 第一行就是 assertToolEnabled,关闭时抛错。
  • 阈值配置入口:面板「实时聊天写作 → 提问节奏」与 novel_sentence_config 的 chatThreshold 是同一份状态字段。

面板预览(只读)

镜像写在数据目录里,如果没有入口,「实时看到他在写什么」这一半就漏掉了——用户只能自己去文件管理器找。所以「诊断报告」页新增草稿镜像卡片:选书 → 章节输入框留空 = 列出该书所有镜像文件(章键 + 体积),填章号 = 显示该章逐段正文;另有「复制目录」按钮(只复制路径)。

数据来自只读路由 GET /api/dsh-novel-writer/draft?book=&chapter=:

请求返回
?book=<书>(chapter 留空){ ok, book, mirrorDir, items: [{ file, chapterKey, bytes?, mtime?, statError? }] }——items 按文件名排序,最多 50 个;chapterKey 是文件名去掉 __draft.md。v7.3.2 起:bytes / mtime 只在 stat 成功时出现,失败时省略这两个键并给 statError(错误码,如 ENOENT / EACCES)——旧版失败时伪造 bytes:0,面板会显示「0 字节草稿」这种假数据。另:列表分支的 readdir 失败也区分了「目录不存在(正常,返回空列表)」与「读不动(整体 500)」
?book=<书>&chapter=<章>{ ok, book, chapterKey, mirrorDir, draftFile, exists, segments: [{ index, text }], totalChars }——chapter 先经 resolveDraftTarget(含同章认领)定位;exists = 内容非空;segments 由 parseDraftSegments 解析;totalChars 是各段文本长度之和
  • 只读:不写、不改、不清理,面板上也没有删除按钮(清理只有两条路:人工删文件,或按书删除时连带清掉)。
  • 仅回环:isAllowedRequest(req, allowLanState) 不通过直接 403;方法不是 GET 返回 405;book 为空返回 400「缺少 book 参数」。
  • 书库根取自面板同一套解析(resolveUiRootInfo),不接受 root 参数。
  • 它不是工具:AI 拿不到这个路由,它只是面板的数据源。
段数不再是 0(v7.3.0 已修正)。预览的 segments 走 parseDraftSegments,而它最初要求段头行以「段」结尾、实际写盘带「· 时间戳」——那会让真实镜像解析出 0 段、totalChars 也是 0。合并时已放宽正则(允许可选的 · 时间戳),并有回归测试与 e2e 断言(真实写入格式 → 必须解析出 2 段)盯着。

它不能并入数据目录那 9 个扁平白名单子目录(DELETE_DATA_SUBDIRS:analysis / audits / baseline / embedding / lexicon / plots / settings / style-reports / summaries)——这是最容易搞错的一处:

那 9 个白名单的口径 = 目录下"扁平文件 + 书名前缀边界" names.filter(name => isBookScopedName(name, keys)) → <书>.json / <书>-<hash>.json / <书>__style_2.json … drafts/ 的口径 = 每本书一个**子目录** drafts/<书>/<章键>__draft.md ├ 文件名是章键(第03章),**不含书名** └ 子目录名才是书名 把 "drafts" 放进那 9 个里 → isBookScopedName 永远命中 0 个文件 → files === 0 → continue 跳过 → 删除书之后留一份看不见的孤儿目录

所以它由 planBookDelete 里一个独立分支(③-b)登记:路径取 draftBookDir(root, book),体积用 pathSize 递归统计,执行时与其它 kind: "dir" 条目一样整目录移入回收站(.novel-writer/trash/<书>-<时间戳>/),restore 原样移回。

顺带一提:诊断也把它算进「数据目录」。novel_diagnose 的子目录统计列表(lib/diagnose.js)已含 drafts,否则「半成品堆了多大」在诊断里完全不可见。

约束与边界

  • 接口面只有 commit:没有读、没有清、没有「改第 N 段」。要改某段就再写一段新的(镜像只追加、不去重),然后把正式稿写对。
  • text 会被 trim:首尾空白不落盘,也不计入字数;纯空白视为未传并抛错。段落内部的换行原样保留。
  • 重复段落照写:同一个 text 提交两次就是两段(不去重、不覆盖)——这是「只追加」的直接后果。
  • 镜像永不被整篇重写:写入只有 appendFile,因此用户在镜像里手加的批注不会被抹掉;账本是另一个文件(__state.json),整体重写也只影响计数。
  • 提问次数的语义:prompts 记的是程序判定要提问的次数,不是模型实际问了几次。模型没照 nextAction 执行时两者会脱节,界面无从校验。
  • remaining 在 ask_user 时不表示「还差 0 字」:它等于阈值本身(批次已清零)。判断「还差多少」请看 nextAction,不要只看这个数字。
  • 章键不是章号:未命名章、哈希兜底名与标题式章名都可能是章键;同一本书里所有「未命名」提交会共用一个镜像与一份账本。
  • 镜像与账本不自动清理:没有 TTL、没有上限、没有「归档」。清理是人工动作(删文件)或按书删除。
  • 书库格式不变:不新增书库内的目录结构,所有新增内容都在既有的 .novel-writer/ 数据目录下;工具数 21 → 22(ALL_TOOLS 第 22 项,TOOL_LABELS 为「草稿镜像」)。

源码位置

  • lib/core.js —— 常量与路径:DRAFT_DIRNAME、DRAFT_FILE_SUFFIX、DRAFT_STATE_SUFFIX、CHAT_PROMPT_THRESHOLD_CHARS、CHAT_THRESHOLD_MIN、CHAT_THRESHOLD_MAX;函数:normalizeChatThreshold、draftRootDir、draftBookDir、draftKeyFor、resolveDraftTarget、draftFileFor、draftStateFileFor、isInsideBookDir、draftMirrorHeader、parseDraftSegments、renderDraftMirror(按函数名搜索;源码里有 v7.3.0 注释块,标题为「实时聊天写作的草稿镜像」)。
  • lib/index.js —— registerNovelDraft(工具定义、execute 与 render);系统提示 section novel-writing:chat-writing;novel_new_chapter 里的「定稿落盘」镜像钩子(搜「定稿镜像」或注释里的 v7.3.0:实时聊天写作);面板只读路由 /api/dsh-novel-writer/draft 的 handler。
  • lib/prompts.js —— CHAT_WRITING_PROMPT(八条纪律正文)、CHAT_MODE_CHECKLIST(一句话版硬约束)。
  • lib/brief.js —— 开写包的 chatWriting 字段(搜 chatWriting,注释「v7.3.0:实时聊天写作」)。
  • lib/bookdelete.js —— planBookDelete 里的 data:drafts 登记(③-b 分支)与 DELETE_DATA_SUBDIRS(不含 drafts)。
  • lib/client.js —— 面板:功能开关 feature.chatWriting、提问节奏 chatThreshold.*、草稿镜像卡片 draft.* 文案与 loadDraftPeek。
  • lib/diagnose.js —— 数据目录子目录统计列表(已含 drafts)。
  • test/chat-writing-test.mjs —— 18 条回归用例:默认关 / 开写包与系统提示两侧 / 只有 commit / 短段累计与达阈值提问 / 只追加 / 独立目录不影响章节扫描 / 非法章名 / 账本持久与重建 / 建章定稿镜像两分支 / 同章不同写法收敛 / 阈值可配置 / 按书删除与诊断登记。