dsh-novelsupport
this is a tool for novel writing.
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 10, 2026
- Updated
- Oct 16, 2026
Introduction
小说辅助插件
接手这个仓库?先读
docs/MAINTENANCE.md。 它写了:三十秒上手(四条命令)、架构地图(13 个宿主模块 / 15 个客户端片段)、 八条必须遵守的不变式、验证纪律、这台机器与沙箱的坑、当前状态, 以及第 9 节——换一个会话接手时可以直接粘贴的开场白。
面向长篇小说创作的 DeepSeek Harness 插件。仓库同时装三样东西:
- DSH 插件(
lib/+client/)—— 给作者用的界面与项目读写 - 三个技能(
.dsh/skills/)—— 给 AI 用的写作与校验指令 - 作品数据(
novel/)—— 两边共读共写的同一份小说
插件是"人侧",技能是"AI 侧",两者靠 novel/ 里的同一份数据耦合。AI 写好的章节,界面能直接看到;界面上改的设定,AI 下次写作就能读到。
安装
这是一个 DSH 组合包(声明了 dsh.bundle),用 dsh plugin 安装进一个 profile:
# 从 npm(推荐:装的是预构建产物,不需要任何构建授权)
dsh plugin --profile desktop add dsh-novelsupport
# 从 GitHub(拉的是源码;本包已随仓库提供构建好的 lib/ 与 client/client.js,所以无需构建)
dsh plugin --profile desktop add github:DJ-STONE36/dsh-novelsupport
# 从本地 checkout 或 tarball
dsh plugin --profile desktop add ./dsh-novelsupport
dsh plugin --profile desktop add ./dsh-novelsupport-0.1.0.tgz
仓库名与包名不一致(仓库
my-project,包dsh-novelsupport)。安装命令以上面为准: 包名决定node_modules里的目录名与插件行解析,仓库名只影响从哪里拉。
装完重启 DSH,GET /dsh-novel/health 应返回 {"ok":true},侧栏出现「小说」入口。
(不想用 CLI 的话,tools\package-plugin.mjs 生成的 INSTALL.md 写的是手工解包进
<profile>/node_modules 的办法 —— 这时目录名必须是包名 dsh-novelsupport。)
它与另外几个小说插件的区别
生态里已有若干 DSH 小说插件(dsh-novel、novel-workbench 等),其中 npm 上的
dsh-novel@0.1.1 属于另一份实现。本包刻意不叫 dsh-novel,因为:
- 数据是纯文件,不是数据库。 设定、大纲、章节都在项目目录的
novel/下 (JSON + Markdown),可以直接用 Git 管理、用任意编辑器改、坏了能手修。 另一份实现把 canon 存进.dsh-novel/pglite数据库。 - 不依赖任何宿主内部包。
peerDependencies为空,所以 DSH 的插件兼容性检查 没有东西可评估,也就不会因为宿主升级而被迫逐行禁用。 - 不需要给宿主打补丁。 「交给 agent 写这一章」走的是
resolveAgent+followup, 不碰sessionController.prompt(那条路需要改已安装的 app.asar)。 - 三栏界面 + 地图 + 时间轴 + 关系网 这些都在客户端,离线自测覆盖 245 项。
目录结构
.
├── package.json 插件清单:dsh.bundle.patch + dsh.client
├── cordis.patch.yml bundle patch:把本插件插入 profile 的层栈
├── lib/
│ ├── index.js 宿主端薄壳(路由前缀注册与服务绑定)
│ └── impl/ 宿主端全部逻辑
│ ├── schema.js 文件清单、默认日历、默认关系类型
│ ├── paths.js 路径围栏:只允许 <root>/novel/** 内的读写
│ ├── store.js 原子写(临时文件 + rename,含 Windows 重试)
│ ├── frontmatter.js 章节 YAML front matter 读写
│ ├── validate.js 结构校验:悬空引用、成环、时间区间
│ ├── scaffold.js 新建项目的规划(纯函数)
│ └── router.js 全部 HTTP 端点
├── client/
│ ├── src/ 浏览器侧源码,按序号拼接(10-core … 90-apply)
│ └── client.js **生成文件**,由 tools/build-client.mjs 拼出
├── .dsh/skills/ 三个技能(平铺)
│ ├── novel-writer/ 正文写作
│ ├── novel-consistency/ 一致性校验
│ └── novel-style/ 文风控制
├── novel/ 作品数据(示例/实例项目)
│ ├── setting/ 世界、地点、人物、组织、物品、地图
│ ├── outline/ 时间轴、剧情大纲
│ ├── draft/ 章节正文(Markdown + front matter)
│ └── views/ 关系网/地图的手工摆位
├── docs/
│ └── data-model.md 数据模型(已确认,含结构演进策略)
└── tools/ 离线与在线自测工具
├── asar-peek.mjs 读宿主 app.asar,核对扩展位签名
├── build-client.mjs 拼接 client/src → client/client.js(无打包器)
├── impl-selftest.mjs 宿主端纯逻辑自测
├── api-selftest.mjs 宿主端接口自测,本地起 http 服务
├── client-selftest.mjs 客户端渲染自测,react-dom/server
├── live-check.mjs 对运行中的宿主做端到端验证
├── audit-sweep.mjs 机械式体检:错误码覆盖、未用导出、跨端常量漂移
├── path-probe.mjs 对运行中的宿主做路径安全探测(只读)
├── patch-asar.mjs 就地修补宿主 app.asar(默认预演;改的是已安装程序,需先关 DSH)
├── agent-smoke.mjs 插件能否驱动 agent(宿主补丁打上后应以 0 退出)
├── recover-items.mjs 从多帧 zstd 会话日志里找回被覆盖的文档(应急)
└── snapshot-novel.mjs 记录 novel/ 的哈希,核对一次 agent 运行究竟改了什么
用例数不写在这里,因为它们每次加测试都会变,写下来就是一处必然过期的数字;每套跑完会自己打印
(OK N/N checks passed)。这里只写每套在测什么。
一次真实的数据丢失,以及它换来的保险
我弄丢了 novel/setting/items.json。 我用一个本该被拒绝的写请求去探测路径处理,宿主正确地接受了它(那个请求本身完全合法——是我的错,不是路径校验的漏洞),文件从 1735 字节变成 {"items": []}。
恢复过程本身值得记录,因为它说明为什么这件事没能收场得更糟,以及哪里本该有保险:
- git 没有提交 —— 没有历史可回滚。
- 没有临时文件残留 —— 原子写入用重命名,写成功就不留痕。
- 会话日志里有 —— 工具结果是逐字记录的。日志是多帧 zstd(追加式),
zlib.zstdDecompressSync只解第一帧(213 字符),必须按 magic28 B5 2F FD切帧逐帧解压,才拿到完整的 1767 万字符。最终找回了 2 个分类 + 2 个条目。 - 但 1735 字节里超出的部分找不回来 —— 那些是你在界面里自己新增并保存的条目,只经过浏览器请求,从未进入任何日志。那部分永久丢失,我只能为此道歉。
由此加上的保险:每次写入都把上一版留成同目录的 <名字>.prev(只留一代)。它刻意不是 .md、也不在 FILES 里,所以永远不会被当成章节或文档内容。这一条同时挡住的还有体检发现的两处静默数据丢失:
content: {}曾被String()变成字面量"[object Object]"整个替换掉一章(front matter 加正文),返回 200,且不报任何校验错误——因为"没有 front matter 的文件"本身不算错误。value: []曾被原样写入,清空整份文档,而校验器的list()会静默跳过非数组,于是报告 0 个错误。
两处现在都按类型拒绝(bad-content / bad-value)。
体检发现与仍未修的部分
三个方向的审计(宿主边界、校验器覆盖、客户端健壮性、文档一致性)共记录约 50 项。已修的:
| 发现 | 处置 |
|---|---|
| 容器有守卫、元素没有 —— 15 处可从手改文件触发的空白面板 | 加 isRecord / objectList,并在组件内部也守一遍(只靠调用方预过滤的组件,一次重构就会再崩);8 条测试专测畸形元素 |
useRefOptions 里一个 null 元素 → .then 无 catch → 该分支所有引用选择器整个会话静默变空 | 过滤元素 + 补 catch |
junction 逃逸 —— 字符串包含性检查对链接完全无效(实测:普通 realpathSync 不解析 junction,lstat 也不把它报成符号链接) | 改用 realpathSync.native 做物理包含性检查;端到端验证:通过 junction 的写入与读取都被拒绝,目标文件未被触碰 |
timeline-event.causeEventId 是自由文本框却从不校验 | 改为 F.ref 点选器 + checkRef |
枚举值无人校验,生成器把 gender 当自由文本交给模型 | 生成器声明 enum 并做包含匹配(「男性(外表)」→ 男);校验器加 ENUMS 表,并由一条客户端测试交叉断言两边一致 |
content / value 类型错误 → 静默毁数据并返回 200 | 按类型拒绝(bad-content / bad-value),且新增写入前备份 .prev |
| 章节无 front-matter id 时永久无法新建章节(409 死循环) | nextChapterId 同时扫文件名;无 id 的章节现在由校验器报 missing-id |
world.json 内容完全不校验 | 分节/规则 id 注册进全局索引 + 重复检查 |
| 关系边/类型、地图打点 id 重复不校验;边自环不校验 | 各自加检查(duplicate-id / self-relation) |
五个实体类型的 custom 空白键漏检 | 补齐 |
文档 JSON 损坏时 /document 返回 500,唯一需要手工修的文件恰好打不开 | 改 422 document-unparseable,带上解析器原话 |
| 一个不可读章节让每一次写入都 500 | loadChapters 逐文件 try/catch |
客户端静默错行为:合并草稿用渲染时捕获的正文;拖拽落盘用闭包里的摆位;1e999 → Infinity → 存成 null;savedAt: undefined 渲染成「已保存 undefined」;空表生成出「undefined渊」 | 全部修(setBody 支持 updater、从 updater 参数读当前摆位、Number.isFinite 守卫、clampToBox 拒绝非有限值) |
校验器把职位的 parentId 拿去查全局索引 → 每个下属职位都"悬空" | 限定在同一组织内解析 |
文档漂移:layout.json 形状少一层、sanitizeItem 已不存在、关系网说明写的是被推翻的旧规则、reflist 过期决定、两个端点未列表 | 全部改正 |
仍未修,都记在这里而不是丢掉:
/project/create写文件时不回滚 —— 若写到一半失败而project.json已落盘,重试会得到project-exists,半成品项目无法通过界面恢复。修法:把project.json放到最后写。- 客户端仍有几处静默错行为:
TimelineBody的元数据表单在meta: undefined时整段消失(不报错);切换项目时时间游标的惰性初始值不会重新同步;/workspaces的 effect 用workspaces.length代替内容做依赖,内容变了但条数没变就不会重跑。 - 物理包含性检查依赖
realpathSync.native—— 这平台上普通realpathSync不解析 junction。若有人把它换回去,junction 防护会静默失效;测试里有真实 junction 所以会被抓住,但这条依赖值得知道是脆的。 - 写入不 fsync,硬杀进程后也不清理临时文件 —— 断电可能留下一个已重命名但内容空白的文件,或一个孤立的
.tmp-*。 /draft的rewrite模式不裁剪整章正文 —— 超长章节会撞上模型上下文上限,报成model-failed,读起来像模型故障而不是"这章太长,一次改不了"。- 文档仍有零星滞后:
organization.status与outline-node.kind的取值集合未在data-model.md写明;§5 仍把九条不变量都写成"违反即拒绝",而代码区分 error/warning,章节引用只是警告。
测试的已知边界(重要):整套测试从不触发任何事件处理器(renderToString 不派发事件),所以每个 onClick 内部都是未执行的。上个版本的多个 bug 正落在那里。同理,布局(裁切、重叠、滚动条)在测试里完全不可见——那类问题只能靠你的眼睛。
api-selftest.mjs 会注入一个可编程的 llm 替身,因此整条生成链路(流式块收集、JSON 抽取、消毒、多备选、错误映射)都能离线跑。宿主的分块判别字段是 type——当初我按 kind 写,静默收集到空字符串,只报"没有 JSON",查了一轮才定位。现在替身按真实形状发块,这条路径不再靠猜。
客户端为什么把呈现层抽成 props 驱动
renderToString 不执行 effect,所以任何"拉数据 → loading → 渲染正文"的组件,在测试里永远停在 loading 那一屏,守卫之后的所有代码一次都不会被渲染。
这不是理论问题:有三个 bug(关系网整片空白、新建的关系无法编辑、关系网显示了不属于它的实体类型)全部落在这一段,而且是你在界面上发现的,不是测试。
所以每个分支的正文都抽成了纯 props 组件——EntityTreeBody、TimelineBody、ChapterBody(含 ChapterListPane / ChapterEditorPane)、GraphView,配合共用的 CommitRow 与 WriteFeedback。测试直接喂数据渲染它们,提交栏、详情表单、删除流程、正文输入框现在都在覆盖范围内。
代价是这些组件要接收十几个 props——对呈现层来说这是正常的,换来的是可验证。
客户端为什么没有打包器
宿主每个插件只服务一个浏览器 bundle,格式是 window.__ModuleLoader__.load({ id, factory })。在那个 factory 里相对 require 解析不了,所以拆分文件通常意味着引入真正的打包器及其配置、插件生态和升级负担。
但这里不需要模块隔离,只需要把若干片段拼进同一个闭包——这正是 tools/build-client.mjs 做的事(约 60 行,零依赖)。它在拼接时校验:片段不得使用 import/export(那会在浏览器加载时才炸,离出错的那次编辑很远),并用 new Function 编译产物以确认语法。
client/client.js 是生成文件,改了会被下次构建覆盖;请改 client/src/。client-selftest.mjs 会断言已提交的 bundle 与源码一致,所以忘记重新构建会直接测试失败。
运行方式
插件通过 junction 挂进桌面 profile(D:\dsh-home\profiles\desktop),并在其 cordis.patch.yml 里有一行 - insert:。改动生效规则:
| 改动位置 | 生效方式 | 代价 |
|---|---|---|
client/client.js | 刷新页面 | 秒级 |
lib/impl/*.js | 下一次请求 | 秒级(mtime 穿透 ESM 缓存) |
lib/index.js | 必须重启宿主 | 打断当前会话 |
所以 lib/index.js 被刻意压到最小:它只注册一条 prefix 路由,其余全部委托给 lib/impl/。加端点、改逻辑都不需要动它。
接口
前缀 /dsh-novel。写操作必须带 x-dsh-novel: 1 头(迫使跨源请求预检,防止网页被诱导写入)。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /dsh-novel | 接口清单 |
| GET | /dsh-novel/ping | 存活探测(不碰磁盘) |
| GET | /dsh-novel/health | 宿主事实 + 服务绑定情况 + impl 各文件 mtime |
| POST | /dsh-novel/project/create | 建项目(含默认日历与目录脚手架) |
| GET | /dsh-novel/project | 项目摘要 + 目录树 |
| GET | /dsh-novel/document | 读文档(JSON 或章节) |
| POST | /dsh-novel/document | 原子写文档 |
| POST | /dsh-novel/chapter | 新建章节(自动编号) |
| GET | /dsh-novel/validate | 全项目结构校验 |
| GET | /dsh-novel/workspaces | 候选项目目录(来自 DSH 的工作区注册表) |
| GET | /dsh-novel/catalog | 生成可用的 provider / 模型 / 风格预设 / 生成器种类(一律不硬编码) |
| GET | /dsh-novel/chapters | 章节列表(path/id/title/order/status/characters/places/summary/wordCount,按叙事 order 排序) |
| GET | /dsh-novel/manuscript | 全书拼成一份 Markdown(titlePage/summary/meta/headingLevel 为查询参数)。返回文本、chapterCount、wordCount、name,以及 skipped——读不出来的章节会列在这里,而不是悄悄缺席 |
| POST | /dsh-novel/manuscript | 把全书写进 novel/export/<书名>.md 并回答路径(absolute + relative + bytes)。这是「导出」的主按钮,因为页面不允许知道下载目录,所以只有写进项目里的文件才有一个可查的地址。路径同样经过 resolveInside 围栏 |
| GET | /dsh-novel/requests | 最近 40 条请求(诊断用,回答"浏览器到底发了什么") |
| GET | /dsh-novel/asset | 读一张地图底图(只允许 setting/map/assets/ 下的图片,按扩展名给 content-type) |
| POST | /dsh-novel/asset | 导入底图(base64 或 data URL;文件名与格式都有白名单,上限 4MB) |
| GET | /dsh-novel/sessions | 工作目录等于本项目的会话(cwd 匹配)+ 默认模型 |
| POST | /dsh-novel/agent/prompt | ⛔ 501 agent-prompt-unsupported(宿主缺陷,见下) |
| GET | /dsh-novel/agent/status | 会话是否还在跑(只查 running) |
| GET | /dsh-novel/agent/result | 从事件流取出 turn/end、agent 文本、用量、它碰过的文件 |
| POST | /dsh-novel/agent/cancel | 取消 |
| POST | /dsh-novel/generate | 让模型生成实体草稿,可一次给多个备选(只返回,不落盘) |
| POST | /dsh-novel/draft | 让模型写某一章正文(读本章设定与前文,只返回,不落盘) |
写入门槛有一条值得说明:只拒绝"本次写入新引入"的错误,而不是要求项目整体干净。写作中的小说天然有悬空引用(人物还没建、章节还没写),要求全书自洽才能保存会让工具不可用。force: true 可越过并在响应里报告引入了什么。
两条生成路线,各自不假装是对方
| 模型生成 | 本地模板 | |
|---|---|---|
| 来源 | 宿主 ctx.llm,读取本项目设定 | client/src/36-templates.js 的内置表 |
| 得到 | 有文采的描述、来历、影响 | 名字、分类、属性、时间 |
| 代价 | 消耗额度、要几秒 | 不联网、不花额度、瞬时 |
| 重复性 | 每次不同 | 同一 seed 完全可复现 |
本地表就是那个"可以改的数据库"。它按 makeRng(seed) 取值,所以同一 seed 必得同一结果——这让它能被测试断言,而不是"看起来随机"。物品的名字、器型 属性、标签只抽一次形制:早先三处各自抽,于是出现了「重华剑」配 器型: 灯 与 标签: 印 这种自相矛盾的草稿。
风格与备选由宿主定义。 客户端只发 styleId,指令文本留在宿主——否则客户端会存下第二份措辞,两边迟早不一致。备选数量(1/3/5)由宿主在 prompt 里声明并夹上限,防止一个客户端 bug 把额度一次烧掉。
本地模板不写描述性文字。 模板无法诚实地写出外貌、性格、影响,所以它不写,而不是编一段看起来像模像样的废话冒充。那部分留给模型或你。
让 AI 写正文
章节编辑器里有一段「让 AI 写这一章」:填一句本次要求、选接着写 / 扩写 / 重写、选风格与模型,正文就会落进编辑框。
它读的不是"写第三章",而是这一章依赖的东西(lib/impl/chapter-context.js):
| 取什么 | 为什么 |
|---|---|
| 本章标题与梗概 | 这一章要交代什么 |
| 它对应的大纲条目 | 这一章要兑现哪个节拍 |
| 出场人物的性别/种族/年龄说明/性格/背景 | 否则模型写的是陌生人 |
| 发生地点的类型与简介 | 场景不是空白舞台 |
| 在册的规则:全部总规则 + 与出场地点同名的分支规则 | 规则约束剧情,而某地规则对不在该地的章节只是噪声 |
| 上一章结尾 | 接住语气与情境,而不是另起一篇 |
| 本章已写到哪 | 从断点续写,不重复 |
产物进入编辑框且未保存——生成永远不会覆盖你没提交的正文。三种模式的合并规则是一个纯函数(mergeDraft):重写替换,接着写与扩写追加,且空章节不会留下开头的空行。
顺带修掉一个真实崩溃:
WriteFeedback只用!== null判断"没有内容",于是省略了 key 的调用方传进undefined——它不是null,于是走到undefined.length把整个面板打崩。现在用Array.isArray判断,并且顺手删掉了关系网里重复的第二份实现。
这一节走的是 ctx.llm。 把整件事交给 agent(ctx.sessionController)是下一步,它有自己的设计问题要先回答:驱动哪个会话、怎么知道 agent 写完了、以及 agent 直接写文件时如何与编辑框里未保存的正文协调。
AI 是怎么接的
插件不自己拿 API key,也不自己发 HTTP。宿主暴露 ctx.llm(模型调用)与 ctx.sessionController(驱动 agent),插件的宿主端通过 ctx.inject 分别绑定它们——分开绑定,因为合成一个 ctx.inject([…]) 时只要有一个服务不存在,整个插件就不会挂载。
/health 会报告这三个服务到底有没有解析成功,以及 llm 提供哪些 provider。这不是装饰:服务没接上时,界面上只会表现为"按钮没反应",没有任何线索。
/generate 刻意不写盘。 它把模型输出消毒成实体声明的确切字段后返回,由客户端填进表单,作者确认后再保存。理由有两条:未经审阅的模型输出不该进入小说;静默消耗额度也不该发生——所以调用后会显示用了哪个模型、花了多少 token。
模型输出是不可信输入。 sanitizeByFields 把结果压进生成器声明的字段集合:未声明的键(包括模型可能编出来的 id)一律丢弃,类型不对的值强制转换,作者的 custom 口袋原样保留。这正是模型输出能安全落进作者文件的前提。
生成器是声明式的。 lib/impl/prompt.js 的 GENERATORS 表里,每一种实体只声明自己的场景与字段,然后共用一个 prompt 构造器与一个消毒器。加一个种类是表里加一项,不是新写两个函数——早先每种各写一份的版本,加「人物」「事件」要写四个函数,也就有四次漏字段的机会。目前覆盖物品 / 人物 / 地点 / 组织 / 事件五种。
生成会读你的设定。 lib/impl/context.js 把世界分节、规则、已有的同类名称(避免重名)、已有的用词(rarity=灵品、race=人类 这类,让新条目读起来像同一本书)以及历法范围汇成一份摘要,随 prompt 一起发给模型。没有它,生成出来的是泛泛的西方奇幻;有了它,「事件」会落在你自己的年份里,「人物」会沿用你已有的种族用词。实测:先生成「守关老将韩戍」,再生成「寒鸦关倒戈(617 年)」——两者自然咬合。
摘要按结构设限(30 个分节 / 15 条规则 / 40 个名称 / 20 条用词),字符上限只是兜底,不是机制:否则一份四千条物品的项目会把真正的指令挤出上下文。
参考字段永远不由模型填写。 characters、lineId、parentId 这类都不在模型的字段表里——模型编出的 id 会指向不存在的实体。涉及的引用由你点选。
分批路:ctx.llm 适合单次生成(物品、起标题、润色);需要读设定、用技能、写多个文件的活(写章节、全书一致性校验)必须走 ctx.sessionController 驱动 agent——那是下一步。
项目模型
一个小说项目 = 一个包含 novel/project.json 的 DSH 工作区目录。
插件不维护自己的项目列表。DSH 的工作区注册表已经是"有序、持久、带标题的项目目录列表",会话也已按它分组;再建一份平行的列表只会多出一个会跟它不一致的真相源。所以 GET /workspaces 直接把注册表里每个目录标上"是否已有小说数据",界面据此提供"在此新建小说项目"。
副作用是好的:小说项目出现在用户本来就用的项目列表里。
界面结构
左栏活动栏一个分支一个图标(7 个),中央是该分支的面板,共用一条头部(项目选择器 / 新建项目 / 返回对话)。选中的项目记在 localStorage,刷新后保持。
| 分支 | 视图 | 状态 |
|---|---|---|
| 世界 | 页签(地点 / 总设定 / 地图) | 三块都可用 |
| 时间轴 | 时间轴 + 时间游标 | 已可用(多线、事件堆叠、历法编辑) |
| 组织 | 页签(组织树 / 关系网) | 已可用(含架构职位树) |
| 人物 | 页签(人物卡 / 关系网) | 已可用(人物卡:出生时间而非年龄) |
| 物品 | 树 + 详情表单 | 已可用(分类树、物品卡、自定义信息、随机生成) |
| 剧情大纲 | 树 + 详情表单 | 已可用(复用物品那套树组件) |
| 章节 | 列表 + 编辑器 | 已可用(元数据表单 + 正文,字数实时) |
世界·总设定用两个扁平集合(分节 / 规则),不套树——setting/world.json 里它们本就是两份并列的数组。
规则做成独立条目而不是埋在大段文字里,因为一致性校验要能指名道姓地说「违反 rule-003」,引用一段散文是没法指认的。scope 决定 branch 是否适用:总规则管全书,分支规则只管某地某线。
顺带修了一个描述符层的不诚实之处:
collections一直只喂给详情面板做查找,没有任何视图真的渲染它——也就是说一个分支可以声明一份列表,而界面永远不会画出来。现在EntityTree会同时渲染树与集合(TreePane/CollectionPane),collections才名副其实。
关系网是唯一写 relations.json 的分支(其他分支只读它)。人物页签与组织页签读同一个边文档,靠 focusKinds 区分:各自只显示本类实体(人物页签只画人物)。与它们直接相连的其他实体要打开「含跨类关系」才出现——那是另一个问题("这些人还挂在什么上"),不该是默认。早先的规则是保留全部相邻者,结果组织与地点被拖进人物关系网。
它有两点值得说:
- 时间游标。 关系带区间,"谁和谁同盟"是关于某一时刻的问题,不是关于这本书的事实。游标停在「最新」时全部关系成立;往前拨,尚未开始或已经结束的关系自动退出。
- 布局是纯函数。 力导向布局由输入完全决定(节点按输入顺序落在圆周上起步,无随机种子、不测量 DOM),所以同一份数据每次画出同一张图,测试也能断言它——随机或依赖 DOM 的布局在浏览器之外无法验证,而"不可验证的布局"正是之前几个 bug 到你眼前才被发现的原因。
可以拖动节点摆位,位置存进 views/layout.json 的 graphs.relations.positions。手工位置逐个节点覆盖自动布局:挪一个节点不会惊动其余,这正是手工摆位的意义。摆位是另一个文档、另一份保存——挪节点不会把关系数据标脏,反之亦然;「重置为自动布局」也只清掉当前视图里的节点,不碰另一个页签里摆好的。
几何换算被单独抽成纯函数(clientToViewBox / clampToBox),因为拖动里唯一无法测试的就是读元素矩形(renderToString 没有盒模型)。把算术抽出来,算术仍然是验证过的,只有"测量"这一步是靠信任。
架构职位树也复用了同一棵树:
structure是用parentId自嵌套的扁平数组(与物品分类同形),所以StructureInput只是把{positions: value}当成一份合成文档喂给TreePane,而不是再写一棵树。删职位会连整棵子树一起删——只删节点会留下parentId指向空处的孤儿。
引用类字段用点选器,不靠手打名字。 SchemaForm 的 ref/reflist 从 REF_COLLECTIONS 取候选(useRefOptions 按文档去重加载,一次请求服务多个字段)。章节与事件的「涉及人物/地点」因此是点选的;front matter 存的是实体 id,一致性校验与关系网都依赖这一点。已存在于数据里、但解析不到实体的 id 会显示为琥珀色未解析标签并被报为 unresolved-chapter-reference——这正是"先写了名字、后建人物卡"的迁移路径:警告列出需要映射的条目。
人物年龄存出生时间(born),不存数字:时间轴跨越千年,存下来的年龄在剧情推进的那一刻就错了,需要时按历法推算。
时间游标是时间轴的核心,不是装饰:关系与事件都是随时间变化的量(docs/data-model.md §4.8),所以"什么是真的"只能相对于某一时刻回答。游标标记那一刻,其上的事件为实、其后的淡出,左右可逐事件步进,也可在轴上点击定位。没有它,视图就只能画"现在"——而在一部跨越千年历法的小说里,"现在"并不是一个良定义的东西。
事件上下堆叠:事件按时间定位,所以同一年里的几个事件会落在同一像素上互相遮挡。胶囊因此分行排列、上下交替,轴线画在泳道正中。重叠判定是纯函数 assignRows(贪心首次适配),用时间比例阈值而非测量 DOM——量 DOM 需要 ref + effect + layout pass,在浏览器之外无法测试,而不可测的布局规则正是"边缘遮挡"那个 bug 得以存活的原因。
右栏详情位有一条已证实的限制(写在 client/client.js 的注释里):宿主右栏按会话存状态,而活动栏分支面板是全局面板,会顶替对话——此时右栏没有会话、显示不出内容(ctx.sidebarRight.mounted 在全局面板下为 undefined)。所以细节扩展区不能委托给宿主右栏,Phase 3 会把它画在分支面板内部(中央内容 + 右侧详情分栏)。注册到宿主右栏的那个标签类型保留,用于"一边对话一边查设定"的场景。
自测
node tools/build-client.mjs # 改过 client/src/ 后必须跑
node tools/impl-selftest.mjs # 宿主端纯逻辑:路径围栏、front matter、校验器
node tools/api-selftest.mjs # 宿主端接口:本地起服务,含路由与拒绝路径
node tools/client-selftest.mjs # 客户端渲染:无浏览器、无宿主
node tools/live-check.mjs # 端到端:需宿主在运行
client-selftest.mjs 用 react-dom/server 真的渲染组件,能抓出语法检查抓不到的东西:闭包里任一处未定义标识符(整个 bundle 是一个闭包,一处拼错会黑掉所有面板)、渲染时抛异常、sidebar.panellist 的 id 与 main 的 key 不匹配(那会让 selectPanel 抛错且界面毫无反应)。它抓不到的是 effect——renderToString 不跑 useEffect,所以所有拉数据的视图只验到加载态。
live-check.mjs 刻意用 node 而非 PowerShell 发请求:Windows PowerShell 的 Invoke-RestMethod -Body <string> 会按系统 ANSI 代码页重新编码请求体,中文会变成乱码,从而冤枉插件。该脚本对标题做了字节级 UTF-8 断言。
三个技能的分工
| 技能 | 负责 | 输入 | 输出 |
|---|---|---|---|
novel-writer | 情节落地:写正文 | novel/setting/、novel/outline/ | novel/draft/ 章节草稿 |
novel-consistency | 写了什么:查矛盾 | 设定 + 大纲 + 草稿 | 冲突报告(默认不改正文) |
novel-style | 怎么写:统一笔调 | 文风档案 | 文风规则 / 润色建议 |
结构化校验由宿主端负责(悬空引用、成环、时间区间),语义一致性由 novel-consistency 负责(性格突变、伏笔未回收)。分工清楚,不重复。三个技能已对齐真实数据格式——见下。
三个技能写的是什么
.dsh/skills/ 是这个设计里的第二个写作者。GUI 走宿主 API(有校验闸门),技能走 agent 的文件工具(没有闸门)——所以技能的指令必须精确到字段名:说错一个字段,agent 就会写出一份校验器要拒绝、或者界面读不出来的文档。
| 技能 | 内容 |
|---|---|
novel-writer | 写前读什么(最小必要集)、章节 front matter 的字段与顺序、id 与文件名规则、正文要求、禁止事项(不改 setting/、不重排 order、不为过校验而删引用) |
novel-consistency | 结构校验怎么跑(GET /dsh-novel/validate)、error 与 warning 的分野、全部 23 个 issue code 的对照表、只做机器做不了的语义判断、报告格式、不自行改正文 |
novel-style | 文风档案存哪(project.json 的 settings.style)、从既有正文归纳而非凭空发明、三种用法、与 GUI 生成预设的关系、检查清单 |
文风档案存在 project.json 的 settings.style,因为 GUI 从不写 project.json(只读),所以 agent 写的档案不会被界面覆盖——这一条由测试断言,而不是靠假设。
防止技能与代码漂移
三份新文档重复了格式描述,而那正是漂移的温床。所以有一组测试机械地核对它们:
- 章节 front matter 的字段顺序必须与
CHAPTER_FIELD_ORDER完全一致(agent 会照抄那个块) - 技能里提到的每个字段名都必须是真实存在的字段(把
ageNote写成ageNotes会失败) - 用到的每个 id 前缀必须是真实实体前缀;允许的例外集合是从
validate.js与schema.js推导出来的,不是手写清单 - 提到的每个文档路径必须是插件真正会写的文件
novel-consistency的 code 表必须不多不少覆盖校验器实际会发的每个 code- 文档里的 URL 必须匹配路由的
PREFIX
这些检查验证过会失败:我故意把 ageNote 改成 ageNotes、并在 front matter 样例里插了一个重复的 status 键,两条都精确报出了漂移。测试不能失败就等于没有测试。
技能实测:留下的样例与复现方法
机械检查只能证明技能与代码一致,证明不了它够用。所以做了一轮真实测试:三个不带任何上下文的 subagent 各自只加载一个技能,去干它描述的那件事。
novel/ 里留下的两件产物就来自那一轮:
| 产物 | 由哪个技能写出 | 它证明了什么 |
|---|---|---|
novel/draft/ch-0003.md | novel-writer | 一个只读技能的 agent 能写出校验器 0 error、front matter 解析干净的章节;所有引用都指向真实实体 |
project.json 的 settings.style | novel-style | 档案写进了正位置,且没丢任何既有字段 |
这两件产物的价值在于它们是"当时那一版技能"的输出——包括它当时的缺陷。settings.style.basis 里就写着这份档案证据薄、banned 六条是通用套路词而非正文实测(那是旧版技能导致 agent 编出来的,现行技能明令禁止)。status: proposed 表示作者还没确认。
复现步骤:
node tools/snapshot-novel.mjs save before—— 记录novel/全部文件的哈希。必须做:agent 用文件工具写入会绕过插件的校验闸门,前后对比是唯一能确知它改了什么的方法。- 起一个不带本对话上下文的 subagent,指令只有「调用
skill工具加载novel-writer,然后照它说的写下一章」。三个技能各一次,按style → writer → consistency的顺序(后者的输入是前者的产物)。 - 让每个 agent 在报告里逐条列出它不得不自己发明的地方——这是整轮测试真正的产出。"技能不错"这种回答没有价值。
node tools/snapshot-novel.mjs diff before—— 核对改动范围,并确认setting/、outline/、relations.json未被触碰。
这一轮共找出 20 处真缺口:novel-style 6 处(最严重的是「向作者确认」对被委派的 agent 是死路,且模板里硬写的对白规则与手稿矛盾)、novel-writer 10 处(最严重的是大纲为空时没有任何退路)、novel-consistency 4 处(branch 的匹配方式未定义、"基准为空"时没有判据)。
顺带还挖出一个真实的校验盲区:没有任何东西检查事件是否落在声明的历法范围内(本项目里就有一个 1234 年的事件、历法却止于 1000 年)。现在有了 event-outside-calendar,判定保守——只写年份的时间跨整年,不会被误报。
Phase 5c:驱动 agent 的实测结论
界面里"让 agent 写这一章"需要宿主把它自己的 sessionController 交给插件。测量了,然后卡住:
| 端点 | 状态 |
|---|---|
GET /sessions?root= | ✅ 能列出工作目录等于本项目的会话(靠 cwd 匹配),并报告默认模型与 running |
GET /agent/status | ✅ 会话是否还在跑(只查 running,不传 transcript) |
GET /agent/result | ✅ 从 inspect 的事件流里取出 turn/end 的 reason、agent 的文本、用量、以及它碰过的文件 |
POST /agent/cancel | ✅ |
POST /agent/prompt | ❌ 501 agent-prompt-unsupported |
为什么 prompt 调不通
sessionController.prompt(options, signal) 的第二个参数是必需的,且必须能被调用 throwIfAborted;但它的参数会被写进会话事件 agent/inbox/spliced,因此必须 JSON 可序列化。两个要求互斥,所以没有任何调用形式能同时满足。六种形式都试过:
| 参数 | 结果 |
|---|---|
prompt(options) | Cannot read properties of undefined (reading 'throwIfAborted') |
prompt(options, {}) | signal.throwIfAborted is not a function |
prompt(options, new AbortController().signal) | 拒绝:non-JSON-serializable |
prompt(options, { throwIfAborted() {} }) | 拒绝:non-JSON-serializable |
prompt(options, Object.create({ throwIfAborted() {} })) | 拒绝:non-JSON-serializable |
prompt({ ...options, signal }) | Cannot read properties of undefined |
失败带着 isDSHRemoteError: true / RemoteError / rpcId,说明这个服务句柄背后是一个跨边界代理。GUI 自己走的是另一条路:Web 应用调用 /api/*(/api/rpc 等),未带凭据时返回 401(而不存在的路由返回 404,所以这些路由确实存在、只是要认证)。插件没有那个凭据。
这不是本项目的数据或参数问题,所以不能靠改本插件修好。updateQueue(看起来是 agent 消费的那个队列,agent/inbox/spliced 描述的正是往队列里 splice)也试了四种形式,都报 Cannot read properties of undefined (reading 'kind') —— 需要一个我未能确定的嵌套 kind 字段。
/agent/prompt 保留在代码里,用语义正确的形式调用,并把这个条件报成独立的 agent-prompt-unsupported(501),这样宿主一旦修好,这里不用改就会通。
报给宿主的问题写成了独立文件:docs/host-bug-session-controller-prompt.md —— 最小复现、六种形式的实测表、推断、以及建议的修法(让 signal 可选并由服务端自建默认值;取消走已有的 cancel(id))。
验证手段:node tools/agent-smoke.mjs。只依赖本插件已注册的路由,不需要界面、不需要凭据。现在输出 BLOCKED 并附上诊断;宿主修好后它会走完 prompt → status → result 并打印 agent 的回复与用量,以 0 退出。
怎么跑这些测试
这台机器的 node 不在 PATH 上,所以 node tools\xxx.mjs 是跑不起来的。用仓库里的包装脚本,它会自己去找 node(先找 PATH,再找 D:\dsh-home\dsh-runtimes\<版本>\dependencies\node\bin,再找常见安装位置):
tools\run.cmd agent-smoke.mjs
tools\run.cmd impl-selftest.mjs
tools\run.cmd api-selftest.mjs
tools\run.cmd client-selftest.mjs
tools\run.cmd live-check.mjs
参数会原样转发:tools\run.cmd agent-smoke.mjs --root D:\some\project。
想绕开包装脚本就直接用完整路径:
& "D:\dsh-home\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" tools\impl-selftest.mjs
各套件在测什么
| 套件 | 需要宿主在跑? | 测什么 |
|---|---|---|
impl-selftest.mjs | 否 | 宿主端纯逻辑:路径包含性、front matter、校验器、原子写入 |
api-selftest.mjs | 否(自带 llm 替身) | 全部端点,含生成与草稿 |
client-selftest.mjs | 否 | 客户端组件渲染、技能与代码的一致性、跨组件的作用域审计 |
live-check.mjs | 是 | 真 HTTP、真文件、UTF-8 往返。写一个临时项目 |
agent-smoke.mjs | 是 | 插件能否驱动 agent(宿主补丁打上后可走通,见上) |
套件里的用例数由每套自己打印,不写在这份文档里——写下来的数字必然过期。
live-check.mjs 会创建项目并写章节,所以它默认写在 <cwd>/.verify,并且拒绝指向已经含 novel/project.json 的目录——把它指向仓库根是个很容易犯的错,那会写进你自己的小说里。
写 .cmd 的两个坑(都在这轮踩过)
- 必须 CRLF 换行。 只有 LF 时 cmd.exe 会把行拆成杂散命令(报
'm' is not recognized),而且看起来和原因毫无关系。有一项测试盯着这条。 shift不会改变%*。 所以包装脚本不能用%*转发参数——那会把脚本名再传一遍;而live-check.mjs把第一个参数当项目根,于是它收到"live-check.mjs"并失败了九项。改成显式转发%1..%9。
地图的数据形状与迁移
一张文档、多张地图,一次只看一张。第一版用的是「图层」——同一画布上叠起来的透明纸——那是错的模型:不同地图是不同的地方,不是叠加层;而且每张「新图层」看起来都继承了上一层的笔迹(因为所有可见图层一起渲染),还共用同一个画布、底图和标记表。
{
"version": 2,
"activeMapId": "map-1",
"maps": [{
"id": "map-1", "name": "大陆全图",
"canvas": { "width": 2000, "height": 1500 },
"background": { "kind": "blank", "asset": null },
"strokes": [{ "color": "#c0504d", "points": [[100, 200], [150, 260]] }],
"pins": [{ "id": "pin-1", "placeId": null, "x": 667, "y": 598, "label": "山门", "color": null, "size": null }],
"labelSize": 34,
"labelFixed": false
}]
}
每笔自带颜色,所以换画笔不会把已经画好的东西改色。labelFixed: false(默认)让标点名称跟笔迹一起缩放;true 则固定像素大小。
旧形状仍然能读,并且在内存里迁移(不写盘,直到作者保存):每个旧图层变成一张地图,共用的画布、底图与标记归第一张。实测作者当前的文档:
图层 1 「图层 1」 1 笔 · 8 标记 ← 8 个标记一个没丢
图层 2 「图层 2」 2 笔 · 0 标记
校验器同时收集 maps[].pins 与旧版顶层 pins 的 id 与 placeId 引用,所以迁移前后都查得到。
修 host 的 prompt:两条路
缺陷定位在 packages/api/session-controller/src/index.ts 第 426 行的 prompt 方法
(@deepseek-ai/dsh-api-session-controller,就是拥有 ctx.sessionController 的那个包):
prompt(request, signal) {
signal.throwIfAborted(); // ← 没有空值检查
return this.commands.prompt(request);
}
而它的 RPC 声明里 parameters 只有 request,signal 属于 cancellation: { parameter: 'signal' }
——取消参数是框架的职责,调用方给不了。同一个文件里 list / retain / nodeStatFile
全都写作 signal?.throwIfAborted(),所以 prompt 是唯一把取消参数当成必需参数的那个。
底层 this.commands.prompt(request) 本来就只收一个参数、完全不碰 signal,所以一个问号就是全部所需的改动。
路 A:改源码后重新构建(推荐)
- 打开
packages/api/session-controller/src/index.ts,约 426 行的prompt:signal.throwIfAborted();→signal?.throwIfAborted();,签名写成signal?: AbortSignal。 - 顺手排查同类写法(都声明了
cancellation.parameter === 'signal'却要求 signal 必须存在):search(query, signal)、verifyDesktopPath(path, signal),以及流式的control(signal)。 - 更根本的改法是让框架在直接调用且调用方未提供取消信号时注入一个真实的 AbortSignal, 这样声明与实现才一致。
- 重新构建安装,然后按下文验证。
路 B:直接改安装好的 app(没有源码也能做)
tools/patch-asar.mjs 就地修补 app.asar。这个补丁是长度中性的:
signal.throwIfAborted(); 与 signal?.throwIfAborted() 都恰好 24 字节(?. 加一、去掉 ; 减一),
所以文件长度不变、档案里没有任何 offset 需要重算,只需同步头部的 integrity SHA256
(十六进制哈希也是定长 64)。整份档案只改两个字节区间,其余 115MB 原样保留。
tools\run.cmd patch-asar.mjs "D:\deepseekharness\resources\app.asar" # 预演,不写任何东西
tools\run.cmd patch-asar.mjs "D:\deepseekharness\resources\app.asar" --apply # 备份后打补丁并回读校验
tools\run.cmd patch-asar.mjs "D:\deepseekharness\resources\app.asar" --revert # 还原最近的备份
默认是预演,因为这是在改本工作区之外的已安装程序。打补丁前先关掉 DSH(运行中的进程占着这个档案),
打完再启动。--apply 会先备份成 app.asar.bak-<时间戳>,写完再回读校验哈希与补丁内容。
两条路都适用的验证
tools\run.cmd agent-smoke.mjs
现在输出 BLOCKED;修好后应当走完 prompt → status → result、打印 agent 的回复与用量、以 0 退出。
详见 docs/host-bug-session-controller-prompt.md。
当前状态
- Phase 0 · 宿主契约验证(活动栏图标、中央面板、磁盘读写、热更新边界)
- Phase 1 · 数据模型确认 + 宿主端项目 API(建项目/读写文档/结构校验/原子写)
- Phase 2 · 外壳与分支导航(7 个分支入口、项目选择与创建、选择持久化)
- Phase 3a · 描述符层 +
SchemaForm(含自定义信息)+EntityTree+ 面板内详情分栏;物品分支已打通 - Phase 3b ·
TimelineView(多线、时间游标、历法编辑)+ 剧情大纲分支(复用树组件) - Phase 4a · 章节编辑器(列表 + 元数据表单 + 正文,字数实时、派生不落盘)
- Phase 4c-1 · 人物 / 地点 / 组织 三个分支(描述符驱动)+ 引用点选器
- Phase 4c-2 · 关系网(
relations.json图视图 + 时间游标 + 确定性力导向布局) - 测试盲区 · 各分支呈现层抽成 props 驱动组件,守卫之后的代码进入覆盖
- Phase 5a · 接上
ctx.llm:五种实体的生成(声明式生成器 + 消毒输出 + 读取作品设定 + 显示模型与 token) - Phase 5a-2 · 模型选择、风格预设、多备选、本地模板生成器(离线免费、可复现)
- Phase 4b · 世界总设定(分节 + 规则条目;
collections现在真的会被渲染) - Phase 5b · AI 写正文(读本章设定与前文;接着写 / 扩写 / 重写;产物未保存)
- Phase 4c-3 · 组织架构职位树(复用树组件);关系网手工拖拽摆位(存
views/layout.json) - Phase 4d · 世界·地图:多张独立地图(各自的范围/底图/笔迹/标记,一次看一张)、手绘(画笔颜色)、地点打点(拖拽 + 关联地点 + 文字与颜色)、平移缩放(滚轮在光标处缩放)、标点名称大小可调(随地图缩放或固定像素)、导入底图(
/asset端点 + 按图片尺寸校正画布) - Phase 4e · 地图的第二轮:地形(河流/道路/边界/山脉,可命名、可改线型与收窄)、图形(矩形/圆形,拖出来)、喷漆(封闭区域填色,最小的区域优先)、橡皮、光标(点选任意元素并拖动,双击打开编辑页)、自由文字、符号图章(9 个图元)、配色主题(5 套,只改纸/网格/边框,不动作者选过的颜色)、地点 ↔ 标记点双向关联、PNG 导出
- 三个 SKILL.md 对齐真实数据格式(写作 / 校验 / 文风),并由测试防止漂移
- Phase 5c · 交给 agent(
ctx.sessionController):已打通- 客户端(
client/src/62-agent.js):选会话 → 交给 agent → 轮询状态 → 读回结果与用量 → 由作者决定是否放进编辑框;501 按原因分别说明 - 两个原因,缺一不可:
- 宿主缺陷(已打补丁):
prompt(request, signal)解引用一个它不可能拿到的 signal。补丁让signal可选,单参数调用才成立 - 我们漏传
requestId(已修):宿主用requestId构造source.rpcId,而会话日志要求无损 JSON 可序列化——值为undefined的字段会被拒绝(JSON 会丢掉这个键)。于是rpcId: undefined让整个agent/inbox/spliced事件写不进去,报成session/agent-busy+ "非 JSON 可序列化"
- 宿主缺陷(已打补丁):
- 第 2 条我一度误判为宿主缺陷并写进了报告,已改正:
docs/host-bug-session-controller-prompt.md现在写明它是我们少传一个字符串,并附上宿主源码与日志校验的原文 - 实测通过:
POST /agent/prompt→ 202 →running=true→ 一轮跑完 →assistant/message→turn/end→ 回复读回(text="收到")。回归测试守住requestId(种回验证会失败) - 工具:
tools\apply-host-patch.cmd(等 DSH 退出后自动打补丁并回读验证)、tools\run.cmd agent-smoke.mjs
- 客户端(
- Phase 6 · 打磨
- 导出全书:
lib/impl/manuscript.js(纯函数,拼 Markdown)+GET /manuscript(返回文本)POST /manuscript(写进项目并回答路径) + 章节分支里的「导出全书」。 按叙事顺序排出;空标题退回 id 而不是编一个「第 N 章」;空章节保留(删掉会让读者手里的书重新编号); 读不出来的章节必须出现在skipped里并显示给作者——悄悄少一章比导出失败更糟。 字数用text.js的同一个countWords,所以编辑器和手稿不会报出两个长度
- 导出给出地址(修
导出的文件没有给出地址,无法找到):主按钮是**「保存到项目里」, 写进novel/export/<书名>.md,界面显示相对路径与完整路径**,并可一键复制。 这是修一个设计错误——早先只有 Blob 下载,而页面不允许知道下载目录,所以那个动作的结果天然没有地址。 「下载」保留为第二选择,并明确写着它的去处由浏览器决定。重复导出会覆盖(派生文件,这是它更新的方式) 并保留一代.prev;export/不在FILES里,所以章节加载器、校验器白名单、备份命名都不会扫到它 - 导入格式(纯文本):
client/src/66-import.js+ 每个实体分支里的「批量导入名字」。一行一个; 可以只写名字,也可以带上已知的信息。行首列表符号会去掉;#开头是注释;同一份粘贴里的重名只建一个; 上限 500。导入前先给方案,而且会把解析结果显示出来("小明 → 性别 男 · 种族 人类 · 年龄说明 23岁"), 这是本项目对"生成物先看再落盘"的一贯规则 - 解析是 schema 驱动的,只写一份:字段名取 schema 的
label(种族:、性别:…), 枚举对得上select声明的选项(状态:在世→status: 'alive'), 词汇表取项目里已有的用词(「人类」之所以被认出,是因为这本书里已经有人在用), 和context.js给模型写 prompt 时读的是同一套东西。因此人物/地点/物品/组织走的是同一个函数,加一个实体种类不用改解析器 - 年龄不写进
born:docs/data-model.md规定存出生时间而不是年龄(存下来的年龄在剧情推进那一刻就错了), 所以「今年23岁」进年龄说明,而「生于617年」这种明确的年份才进born: { year: 617 }。 真实句子读不懂的部分保留在该实体的散文字段里(人物→背景 / 地点→简介 / 物品→描述),不丢 - 实体由「+新建」同一个工厂创建(
childNodeFor),id 用mintId从名字生成(char-李明), 未声明的字段一律丢弃(否则会给文档写进一个没有表单能编辑、校验器也不认识的键); 写盘走和其他编辑完全一样的onMutate→ 保存,绕不过校验器也绕不过.prev备份(实测 HTTP 200) - 打包:
tools/package-plugin.mjs→dsh-novel-0.0.1.tgz(18 个文件,238 KB)+INSTALL.md。 不是npm pack:这台机器上没有 npm/pnpm(见宿主补丁笔记),所以.tgz由这个工具自己写 (ustar 512 字节头 +node:zlib),没有子进程、没有依赖——也因此可验证: 它会把自己刚写的档案读回来、逐字节比对、校验每条的 tar checksum、解到临时目录后 真的import()宿主半并确认导出了apply(),以及解析一遍客户端 bundle。 它拒绝打包四种情况:测试套件失败(--force可越过并会明说)、代码里有写死的绝对路径、package.json声明的入口不存在、相对 import 解析不到——这四种正是「打得出来、装上去却跑不起来」的原因。package.json的files是声明式的(推断出来的清单会把tools/和临时目录一起装进去), 并有测试守住「声明的每个文件都存在」(种回验证会失败) - i18n(当前只有中文界面;作者也是中文,优先级最低)
- 导出全书:
闭环已经跑通
一条真实链路(在临时项目上验证,未改动本仓库数据):
总设定里写下 → [北境] 北境无灵:寒潮期间北境灵气枯竭,法术失效。
↓ context.js 汇成摘要
↓ prompt 里带上「世界规则」
生成物品,方向「一件能在北境使用的器物」
↓
霜心火匣 —— 以寒铁为壳、乌铜为芯……不借半分灵气,
是北境寒潮中少数仍能生效的器物。
模型读到了规则并绕着它设计。这正是把规则做成独立条目、并在生成时读设定的意义:设定不是给人看的备注,而是会实际约束产出的东西。
有意推迟的决定
- 自动保存:暂不做。理由不是工作量,而是风险——
novel-writer会直接写novel/draft/*.md,自动保存若与 agent 的写入重叠就是静默互盖。要做就得配乐观并发(写入带上基准版本,被外部改动则拒绝),那是独立的一块。
数据模型的完整定义见 docs/data-model.md。