DJ-STONE36/dsh-novelsupport ↗★ 0
dsh-novelsupport
Novel-writing workspace for DeepSeek Harness: a three-pane UI with map, timeline, character/place/item/organisation graph, chapter editor, full-book export and plain-text import — stored as plain files in the project. 适合小说创作者,提供大纲、角色关系图及 AI 协同写作
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:DJ-STONE36/dsh-novelsupport小说辅助插件
接手这个仓库?先读
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 写的是手工解包进
/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 路径围栏:只允许 /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