novel_draft
实时聊天写作的草稿镜像:AI 一段段地写,每写完一段就把这一段正文交给本工具,由程序追加进独立目录 .novel-writer/drafts/<书>/ 并自行累计字数;只追加,AI 不读、不改、不清;正式章节文件在用户确认之前一个字节都不动。它不生成正文,也不判断「该不该问人」——问不问、什么时候问,由程序按字数阈值决定,工具只把结论放在 nextAction 里(continue / ask_user)。
为什么必须另起一个目录
这是整个功能的设计核心,也是唯一一处「放错位置就会静默把整本书的数字搞坏」的地方。
插件所有章节扫描都按 <书库根>/novels/<书>/ 下的文件枚举(core.scanChapters),并把每个 .md 当成一章参与统计:章节数、总字数、六维基线、语义索引、风格自检、关键词、连贯性审计……全部从这份枚举派生。只要把「正在写的半成品」写进那个目录,这些数字就会被草稿污染——同一章出现两份、章号撞号、字数虚增、基线漂移、语义索引里混入还没定稿的段落。
更麻烦的是它不会报错:界面上只表现为「数字怪怪的」「同一章好像有两份」「基线忽然变了」,用户很难据此自查,模型也无从判断。
因此草稿镜像固定落在数据目录里,与正文目录同级但不相交:
工具层有一道硬断言:每次计算镜像路径后都跑一次 isInsideBookDir(root, book, target),一旦路径落进正文目录就直接拒绝执行:
isInsideBookDir 比较的是 resolve() 之后的绝对路径,并按目录边界判定(b === a 或 b 以 a + 路径分隔符 开头),因此 …/novels/雨夜灯甲/x 不会被误判成 …/novels/雨夜灯 内部。解析失败(拿不到可用绝对路径)时返回 false,即「不敢断言在内部」——真正拦人的是路径本身由 draftBookDir 拼出,永远落在 .novel-writer/drafts/ 下。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
action | string | 是 | 枚举只有一个值 commit(提交这一段正文)。传 open / read / clear / append 等任何其它值都抛「草稿镜像只支持 action=commit(只追加)。它由程序维护、不交给 AI 读改清——需要清理请人工删除 <书库>/.novel-writer/drafts/ 下的文件」。 |
book | string | 是 | 书名,即 novels/ 下的子目录名。经 sanitizeSegment 过滤后同时用于拼镜像目录。 |
chapter | string | 否 | 章标识(章号 / 第NN章 / 标题),只用来命名镜像文件与账本,不改变写入内容。省略或纯空白 → 一并按「未命名章」处理(draftKeyFor 的兜底也是同一个词)。 |
text | string | 是 | 只传这一段的正文,不是整章。经 requiredString 取用,因此首尾空白会被 trim() 掉,纯空白视为未提供并报「参数 "text" 不能为空字符串」。重复段落也照写,程序不去重。 |
root | string | 否 | 章节库根目录。 |
契约口径:required: ["action", "book", "text"],additionalProperties: false;参数表里没有 read / clear / index——「只追加」不是文档承诺,而是接口面上不存在修改某一段的能力(test/chat-writing-test.mjs 用断言钉住这一条)。
输出结构
契约必返字段(output.schema.required,共 13 个):
| 字段 | 类型 | 内容 |
|---|---|---|
action | string | 恒为 commit(归一后的动作) |
book | string | 净化后的书名 |
chapter | string | 章键(镜像文件名前缀,如 第03章 / 未命名章),不是入参原文;入参写 "3"、"第03章"、"第03章 证词里的空白" 都回 第03章 |
mirrorDir | string | 镜像目录绝对路径(…/.novel-writer/drafts/<书>),给人看 |
draftFile | string | 该章镜像文件绝对路径(…/<章键>__draft.md),给人看 |
segmentIndex | integer | 本次写入是第几段(从 1 起;由账本续号,账本丢失时按镜像段头重建) |
segmentChars | integer | 本段字数(text.trim() 之后的 JS 字符串长度) |
pendingChars | integer | 本批已累计多少字(真实累计值)。达阈值那一批会略超阈值(例如阈值 150、本批 162),因为段落按整段累加、不会截断——这个字段报的就是真实的 162,不是阈值本身 |
threshold | integer | 本次生效的程序阈值(字),见「提问节奏」 |
remaining | integer | max(0, threshold - 本批剩余待确认字数)。批次刚被提问时计数器已清零,所以 ask_user 的返回里它等于 threshold(表示「下一批从头累计」),而不是 0;渲染文本在 ask_user 时改写成「本批 162 字 / 阈值 150 字」,避免读成「162 / 150 还没到」 |
prompts | integer | 本章累计提问次数(只在 ask_user 那一次 +1) |
nextAction | string | 枚举 continue(继续写下一段,别去问人) / ask_user(程序判定该问了) |
message | string | 一句中文结论:落了第几段、待确认多少 / 阈值多少、还差多少,或「已达阈值,现在停下来问用户」 |
combinedText | string | 仅 ask_user 时出现:本批全部段落用空行合并后的正文(贴给用户看的那份) |
question | string | 仅 ask_user 时出现:原样交给提问工具的整段问题,正文已含在 combinedText 里 |
ask_user 时 question 的固定文案(程序拼,不由模型改写):
渲染层(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
| 输入 | 落盘 / 生效 |
|---|---|
400 | 400 |
800.4 | 800(round) |
50 | 100(夹到下限) |
999999 | 5000(夹到上限) |
0 / null | 清回默认 400 |
"abc" / 非数字 | 忽略该次写入,闸门按默认 400 |
阈值不是每章一份:它存在状态文件 state.json 的顶层 chatThreshold(面板「实时聊天写作 → 提问节奏」那一行,或 novel_sentence_config 的 chatThreshold 参数),全书生效;读取侧取值口径是 state.chatThreshold ?? 400。
判定:达到或超过即提问
阈值只决定「何时问」,不影响落盘:每一段都是立刻 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 产出:
入参 chapter | 章键 | 说明 |
|---|---|---|
3 | 第03章 | 能解析出章号就统一成规范写法 |
第03章 证词里的空白 | 第03章 | 标题被丢掉:同章只能有一个镜像 |
第2章.md | 第02章 | 后缀不影响 |
| 省略 / 空串 | 未命名章 | 兜底名,同一本书的「未命名」会共用一个文件 |
a/b/c | abc | 路径分隔符被清掉(不会越界) |
*** | 章-36c3eaa0 | 清洗后为空 → 稳定短哈希 |
"a" × 300 | aaaa…aaaa-003ef1 | 截到 48 + 6 位哈希(Windows 260 字符路径上限) |
chapter:"3" 得到 3__draft.md,而建章时用文件名 第03章 证词里的空白 又得到另一个文件——同一章两份镜像,用户看到的那份恰好只有「定稿」,于是以为「镜像没多写」。现在只要能解析出章号就统一成 第NN章。
归一之外还有第二层兼容:resolveDraftTarget 在规范文件不存在时,会扫一遍该书的 __draft.md,认领章号相同的第一份既有文件(按文件名排序)作为本章镜像与账本:
这一层是为了兼容早期按字面量命名的镜像(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 字)。本文件是草稿镜像,只追加、只给人看。
new Date().toISOString() 截到秒(YYYY-MM-DD HH:MM:SS,不带时区后缀、也不是本地时间),所以文件里看到的时刻通常比北京时间早 8 小时。它只是给人看的标记,不参与任何判断。
## 第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(关闭时整个键省略):
渲染文本里对应分区是【实时聊天写作·逐段确认】,逐行打出镜像文件、镜像目录、「程序阈值:T 字(由程序判定何时提问,不要自己决定)」、howTo,末尾一条硬规矩:「每段写完用 novel_draft 交出去并照 nextAction 执行;用户说不对就只改他指出的那几段;确认前不要写正式章节文件」。同时 plan.checklist 追加 CHAT_MODE_CHECKLIST。
chatWriting 只算出路径、不落盘;drafts/<书>/ 由第一次 commit 创建(mkdir(recursive))。同理,镜像路径计算失败只会在 degraded 里记一句「实时聊天写作:草稿镜像路径计算失败(本章不注入该模式)」,不影响其余材料。
定稿落盘(novel_new_chapter)
chatWriting 开启时,novel_new_chapter 在章节写盘成功后由插件自己往该章镜像里追加一条「定稿落盘」记录——这件事 AI 不需要、也不该管:
规则的理由很实在:镜像只追加,如果每次都把整章正文再拷一遍,镜像就会与正文等长、看起来像「镜像没多写」;反之若 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 拿不到这个路由,它只是面板的数据源。
segments 走 parseDraftSegments,而它最初要求段头行以「段」结尾、实际写盘带「· 时间戳」——那会让真实镜像解析出 0 段、totalChars 也是 0。合并时已放宽正则(允许可选的 · 时间戳),并有回归测试与 e2e 断言(真实写入格式 → 必须解析出 2 段)盯着。
它不能并入数据目录那 9 个扁平白名单子目录(DELETE_DATA_SUBDIRS:analysis / audits / baseline / embedding / lexicon / plots / settings / style-reports / summaries)——这是最容易搞错的一处:
所以它由 planBookDelete 里一个独立分支(③-b)登记:路径取 draftBookDir(root, book),体积用 pathSize 递归统计,执行时与其它 kind: "dir" 条目一样整目录移入回收站(.novel-writer/trash/<书>-<时间戳>/),restore 原样移回。
约束与边界
- 接口面只有
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);系统提示 sectionnovel-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 / 短段累计与达阈值提问 / 只追加 / 独立目录不影响章节扫描 / 非法章名 / 账本持久与重建 / 建章定稿镜像两分支 / 同章不同写法收敛 / 阈值可配置 / 按书删除与诊断登记。