dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
接手这个仓库? 先读 docs/data-model.md,以及下面的「安全模型」与「测试」。 维护者的交接记录、崩溃取证与沙箱笔记含本机路径,不随仓库分发;它们只在维护者本地的私有目录里。
面向长篇小说创作的 DeepSeek Harness 插件。仓库同时装三样东西:
lib/ + client/)—— 给作者用的界面与项目读写.dsh/skills/)—— 给 AI 用的写作与校验指令novel/)—— 两边共读共写的同一份小说,只在本机,不纳入 Git插件是"人侧",技能是"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
仓库名与包名都是
dsh-novelsupport,插件 id 仍是dsh-novel(路由前缀、写头、技能命名空间)。 安装命令以包名为准:包名决定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 的插件兼容性检查
没有东西可评估,也就不会因为宿主升级而被迫逐行禁用。resolveAgent + followup,
不碰 sessionController.prompt(那条路需要改已安装的 app.asar)。插件与宿主同进程,但它的 HTTP 面比宿主自己的路由更靠外:/dsh-novel/** 是
webServer.register() 挂的前缀路由,不在宿主的 token 校验与 Host/Origin 栅栏之后。
所以插件自己实现了同等的栅栏:
localhost / 127/8 / ::1)、宿主监听地址,或配置的 trustedHosts;Sec-Fetch-Site: cross-site 一律拒绝;webStartup 服务时退化为只允许 loopback。其他边界:
x-dsh-novel: 1(挡住普通跨源表单)——它是第二层,不是唯一防线。paths.resolveInside:拒绝 ..、绝对路径、盘符、UNC、NUL、ADS、设备名,
并用 realpathSync.native 挡住 junction/symlink 逃逸。maxAliases: 0)+ 128 KB 上限。CSP: sandbox 与 nosniff;地图用 <image> 渲染,直接导航也不会执行脚本。expectedMtimeMs,不匹配返回 409 document-changed,避免静默覆盖 agent
或另一个窗口的改动;.prev 是保险,不是合并。/agent/result)要求 root 且必须等于会话的 cwd;会话 id 本身不被当作权限。四套自测共 436 项:impl 97 项(纯逻辑)· api 81 项(真实 HTTP)· client 248 项(纯函数 + 源码结构审计)· live 10 项(真项目、真写盘)。
运行方式见下面的「怎么跑这些测试」。对运行中的宿主做安全复验:
tools\run.cmd security-check.mjs <http://127.0.0.1:端口/dsh-novel> <一个新的空目录>
.
├── 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/ 本地作品数据(.gitignore 忽略,不随仓库分发)
│ ├── 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 对运行中的宿主做端到端验证
├── security-check.mjs 对运行中的宿主复验栅栏 / 转录 / 并发 / SVG 加固
├── 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 运行究竟改了什么
用例数只写在上面「测试」一节(client-selftest 会解析它与真实运行数对账);其余地方每套跑完自己打印
(OK N/N checks passed)。这里只写每套在测什么。
novel/ 不在版本库里。 .gitignore 忽略整个目录,git add -A 不会带上它;
插件、技能、测试与文档都不依赖仓库里的作品数据(测试一律用临时目录)。
想给作品做版本管理,请用私有仓库或单独的工作树,不要把它加回这个公开仓库。
我弄丢了 novel/setting/items.json。 我用一个本该被拒绝的写请求去探测路径处理,宿主正确地接受了它(那个请求本身完全合法——是我的错,不是路径校验的漏洞),文件从 1735 字节变成 {"items": []}。
恢复过程本身值得记录,因为它说明为什么这件事没能收场得更糟,以及哪里本该有保险:
zlib.zstdDecompressSync 只解第一帧(213 字符),必须按 magic 28 B5 2F FD 切帧逐帧解压,才拿到完整的 1767 万字符。最终找回了 2 个分类 + 2 个条目。由此加上的保险:每次写入都把上一版留成同目录的 <名字>.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 所以会被抓住,但这条依赖值得知道是脆的。.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",查了一轮才定位。现在替身按真实形状发块,这条路径不再靠猜。
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 写这一章」:填一句本次要求、选接着写 / 扩写 / 重写、选风格与模型,正文就会落进编辑框。
它读的不是"写第三章",而是这一章依赖的东西(lib/impl/chapter-context.js):
| 取什么 | 为什么 |
|---|---|
| 本章标题与梗概 | 这一章要交代什么 |
| 它对应的大纲条目 | 这一章要兑现哪个节拍 |
| 出场人物的性别/种族/年龄说明/性格/背景 | 否则模型写的是陌生人 |
| 发生地点的类型与简介 | 场景不是空白舞台 |
| 在册的规则:全部总规则 + 与出场地点同名的分支规则 | 规则约束剧情,而某地规则对不在该地的章节只是噪声 |
| 上一章结尾 | 接住语气与情境,而不是另起一篇 |
| 本章已写到哪 | 从断点续写,不重复 |
产物进入编辑框且未保存——生成永远不会覆盖你没提交的正文。三种模式的合并规则是一个纯函数(mergeDraft):重写替换,接着写与扩写追加,且空章节不会留下开头的空行。
顺带修掉一个真实崩溃:
WriteFeedback只用!== null判断"没有内容",于是省略了 key 的调用方传进undefined——它不是null,于是走到undefined.length把整个面板打崩。现在用Array.isArray判断,并且顺手删掉了关系网里重复的第二份实现。
这一节走的是 ctx.llm。 把整件事交给 agent(ctx.sessionController)是下一步,它有自己的设计问题要先回答:驱动哪个会话、怎么知道 agent 写完了、以及 agent 直接写文件时如何与编辑框里未保存的正文协调。
插件不自己拿 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 区分:各自只显示本类实体(人物页签只画人物)。与它们直接相连的其他实体要打开「含跨类关系」才出现——那是另一个问题("这些人还挂在什么上"),不该是默认。早先的规则是保留全部相邻者,结果组织与地点被拖进人物关系网。
它有两点值得说:
可以拖动节点摆位,位置存进 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 写的档案不会被界面覆盖——这一条由测试断言,而不是靠假设。
三份新文档重复了格式描述,而那正是漂移的温床。所以有一组测试机械地核对它们:
CHAPTER_FIELD_ORDER 完全一致(agent 会照抄那个块)ageNote 写成 ageNotes 会失败)validate.js 与 schema.js 推导出来的,不是手写清单novel-consistency 的 code 表必须不多不少覆盖校验器实际会发的每个 codePREFIX这些检查验证过会失败:我故意把 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 用文件工具写入会绕过插件的校验闸门,前后对比是唯一能确知它改了什么的方法。skill 工具加载 novel-writer,然后照它说的写下一章」。三个技能各一次,按 style → writer → consistency 的顺序(后者的输入是前者的产物)。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,判定保守——只写年份的时间跨整年,不会被误报。
界面里"让 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 |
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),这样宿主一旦修好,这里不用改就会通。
报给宿主的问题写成了独立记录(含本机路径与宿主源码摘录,不随仓库分发)—— 最小复现、六种形式的实测表、推断、以及建议的修法(让 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 的目录——把它指向仓库根是个很容易犯的错,那会写进你自己的小说里。
'm' is not recognized),而且看起来和原因毫无关系。有一项测试盯着这条。shift 不会改变 %*。 所以包装脚本不能用 %* 转发参数——那会把脚本名再传一遍;而 live-check.mjs 把第一个参数当项目根,于是它收到 "live-check.mjs" 并失败了九项。改成显式转发 %1..%9。一张文档、多张地图,一次只看一张。第一版