DJ-STONE36/dsh-novelsupport ↗★ 0

dsh-novelsupport

面向长篇小说创作的三栏式辅助工作区 适合小说创作者,提供大纲、角色关系图及 AI 协同写作

包名
dsh-novelsupport
兼容性
待验证
版本
0.1.0
许可证
MIT
最近更新
2026年10月16日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:DJ-STONE36/dsh-novelsupport

小说辅助插件

接手这个仓库?先读 docs/MAINTENANCE.md。 它写了:三十秒上手(四条命令)、架构地图(13 个宿主模块 / 15 个客户端片段)、 八条必须遵守的不变式、验证纪律、这台机器与沙箱的坑、当前状态, 以及第 9 节——换一个会话接手时可以直接粘贴的开场白。

面向长篇小说创作的 DeepSeek Harness 插件。仓库同时装三样东西:

  1. DSH 插件(lib/ + client/)—— 给作者用的界面与项目读写
  2. 三个技能(.dsh/skills/)—— 给 AI 用的写作与校验指令
  3. 作品数据(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": []}。

恢复过程本身值得记录,因为它说明为什么这件事没能收场得更糟,以及哪里本该有保险:

  1. git 没有提交 —— 没有历史可回滚。
  2. 没有临时文件残留 —— 原子写入用重命名,写成功就不留痕。
  3. 会话日志里有 —— 工具结果是逐字记录的。日志是多帧 zstd(追加式),zlib.zstdDecompressSync 只解第一帧(213 字符),必须按 magic 28 B5 2F FD 切帧逐帧解压,才拿到完整的 1767 万字符。最终找回了 2 个分类 + 2 个条目。
  4. 但 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,带上解析器原话
一个不可读章节让每一次写入都 500loadChapters 逐文件 try/catch
客户端静默错行为:合并草稿用渲染时捕获的正文;拖拽落盘用闭包里的摆位;1e999 → Infinity → 存成 null;savedAt: undefined 渲染成「已保存 undefined」;空表生成出「undefined渊」全部修(setBody 支持 updater、从 updater 参数读当前摆位、Number.isFinite 守卫、clampToBox 拒绝非有限值)
校验器把职位的 parentId 拿去查全局索引 → 每个下属职位都"悬空"限定在同一组织内解析
文档漂移:layout.json 形状少一层、sanitizeItem 已不存在、关系网说明写的是被推翻的旧规则、reflist 过期决定、两个端点未列表全部改正

仍未修,都记在这里而不是丢掉:

  1. /project/create 写文件时不回滚 —— 若写到一半失败而 project.json 已落盘,重试会得到 project-exists,半成品项目无法通过界面恢复。修法:把 project.json 放到最后写。
  2. 客户端仍有几处静默错行为:TimelineBody 的元数据表单在 meta: undefined 时整段消失(不报错);切换项目时时间游标的惰性初始值不会重新同步;/workspaces 的 effect 用 workspaces.length 代替内容做依赖,内容变了但条数没变就不会重跑。
  3. 物理包含性检查依赖 realpathSync.native —— 这平台上普通 realpathSync 不解析 junction。若有人把它换回去,junction 防护会静默失效;测试里有真实 junction 所以会被抓住,但这条依赖值得知道是脆的。
  4. 写入不 fsync,硬杀进程后也不清理临时文件 —— 断电可能留下一个已重命名但内容空白的文件,或一个孤立的 .tmp-*。
  5. /draft 的 rewrite 模式不裁剪整章正文 —— 超长章节会撞上模型上下文上限,报成 model-failed,读起来像模型故障而不是"这章太长,一次改不了"。
  6. 文档仍有零星滞后: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