# 小说创作搭档(dsh-novel-companion) 一个 DSH 插件:它会像编辑一样不断向你提问,把世界观、人物、情节打磨完整;每一章先写大纲给你改,你定稿后它再写正文。 --- ## 一、它是怎么工作的 两个入口,共用同一份数据: | 入口 | 位置 | 用途 | | --- | --- | --- | | **小说面板** | 左侧边栏的书本图标 → 主区域 | 自己动手改设定、大纲、正文 | | **`novel` 工具** | 对话里由模型调用 | 模型读写同一份项目,问你问题、把回答写回、写大纲和正文 | 数据只存在一处:DSH 的存储域 `novel_companion`(`projects` 表,`single` 布局,一个 JSON 文件)。面板和工具都是它的读写窗口,不会各存一份。 ### 创作流程(7 个阶段) ``` 1 了解作者 → 2 世界观 → 3 人物 → 4 情节 → 5 分章大纲 → 6 写正文 → 7 收尾 ``` 每个阶段对应面板顶部的阶段条,点一下就能切换,也可以用「下一步 →」。 ### 六个设定板块 | 板块 | 内容 | | --- | --- | | `core` 内核 | 一句话故事、核心冲突、结局走向 | | `world` 世界观 | 时代、地点、规则、势力 | | `characters` 人物 | 主角、对手、配角的小传与动机 | | `plot` 情节 | 主线、支线、伏笔与回收 | | `rules` 设定规则 | 硬设定、禁忌、代价(避免自相矛盾) | | `style` 文风 | 叙事人称、节奏、腔调 | 每个板块有三种状态:`draft` 草稿 / `final` 定稿;还带一份**待问问题清单**,模型问完一条就从清单里删一条。 ### 每一章的推进 ``` outline 大纲 → writing 写作中 → final 成稿 ``` 1. 面板或模型写出一章大纲; 2. **你改大纲**(这是设计上最重要的一步,改完再定稿); 3. 模型按定稿大纲写正文; 4. 你决定是否把这一章标记为 `final`。 --- ## 二、面板怎么用 **左侧列表** - 顶部输入框 + 「新建」→ 建一本新书; - 点击书名切换项目,双击删除(需要二次确认); - 一个项目都没有时,会显示「载入示例」,可以直接看看完整的一本长什么样。 **右侧工作区** - 顶部:书名、题材、一句话故事; - 阶段条:7 个阶段,点「下一步 →」顺序推进; - 「设定」页签:左侧选板块,右侧改正文,改完勾选「定稿」; - 「章节」页签:新建章节、写大纲、贴正文、标记成稿; - 底部按钮会把一段**写好的提问/指令**放进对话框的草稿栏(不会冒充你发消息),你确认后按发送即可。 **自动保存**:停止输入约 1.2 秒后自动保存;切换项目或关闭面板时会把没存的内容补存一次。 **冲突保护**:面板每次保存都带着读到的版本号(`rev`)。如果你打开面板后模型在对话里改过这本书,面板手里的旧副本会**被拒绝而不是覆盖**,面板会提示「对话里已经改过这个项目,已刷新到最新版本;刚才那次编辑没有保存」并自动拉取最新内容;反过来,你在面板里刚存的稿子也不会被模型后续写入冲掉。 **章节三态**:选中一章后,大纲和正文之间那行按钮可以选「大纲 / 写作中 / 已定稿」,与模型看到的状态一致(此前面板只能在「大纲 ↔ 已定稿」之间切)。 **写入对话失败的提示**:如果没有可用的对话输入框(例如打开了别的面板),按钮会提示「复制失败」,这时可以手动复制文本。 --- ## 三、`novel` 工具怎么用(模型侧) 模型在对话里通过 `novel` 工具读写项目。`action` 一览: | action | 作用 | 关键参数 | | --- | --- | --- | | `list` | 列出所有项目(只有索引:阶段、进度,不含正文) | — | | `get` | 读取项目:6 个板块全文 + 全部章节大纲 + 有预算的正文;给 `chapterId` 时只展开该章全文 | `projectId`、`chapterId` | | `create` | 新建项目 | `title`、`genre`、`premise` | | `stage` | 切换阶段,同时返回 `readiness`(没准备好会给出原因) | `stage` | | `block` | 写入某个设定板块 | `blockId`、`content`、`status`、`questions` | | `outline` | 保存某一章大纲(接受 `outline/writing/final` 三态) | `chapterId`、`outline`、`status` | | `chapter` | 保存某一章正文 | `chapterId`、`draft`、`status` | | `appendChapter` | 追加一章(到 500 章上限会显式报错,不会静默丢章) | `title`、`outline` | `get` 的正文是有预算的:默认展开「写作中」的章和最近 2 章有正文的章(每章上限 6000 字),其余章只给字数和 `action=get chapterId=…` 的读取提示,所以 100 章的书也不会一次塞爆上下文。单字段超过 20 万字会**拒写并提示拆章**,不再静默截断。 失败不会被渲染成「已更新」:工具操作失败时模型读到的是「小说项目操作失败:…」,不会误以为写成功了。 模型在系统提示里会看到一条「小说创作档案」摘要(项目列表、阶段、哪些板块还没定稿、哪些章还缺大纲),所以它知道当前进度,不需要你重新交代背景。 **推荐的对话节奏**(插件已把这条规则写进提示词):一次只推进一件事——问一个问题、改一个板块、写一章大纲;写回之后再说下一步。 --- ## 四、数据在哪、怎么备份 - 存储域:`novel_companion`,表 `projects`; - JSON 布局(`single`),整个项目集合是一个可读、可 diff、可直接备份的文件; - 每个项目有稳定的 `id`(形如 `novel-1a2b3c…`),面板用它作存储键; - 文档带 `version: 1`,读取时会自动修正缺失字段、丢弃未知字段,所以旧数据不会让面板报错; - 文档还带 `rev`(单调递增的版本号):面板保存时提交自己读到的 `rev`,不一致就拒绝写入,避免「最后写的人赢」把别人刚写的内容冲掉。 --- ## 五、在哪里找到它 启用后,界面上有三个入口,容易找错地方: | 位置 | 说明 | | --- | --- | | **左侧栏的书本图标** | 已注册(`sidebar.panellist` 的 `novel`,order 20)。点开就是小说面板,这是主要入口 | | **侧栏「插件」页 →「已安装」分组** | 这是第三方插件的分组;「官方」分组只列 DSH 自带的组合包,在那里**永远看不到**本插件 | | **设置 → 插件列表 →「全局」分组** | 这是只读清单。⚠️ 该页**每次打开只取一次快照、不订阅变化**,而且「全局」分组默认折叠 | 如果你是在设置页开着的时候启用的插件,那个列表不会自动更新 —— **关掉设置再重新打开**就能看到(或直接用搜索框搜「小说」)。 启用/禁用后仍然需要**完全退出并重新打开 DSH 桌面端**:Host 半(存储、Remote 接口、`novel` 工具)只在启动时加载一次,热更新只覆盖 Client 半。 正常运行的自检特征(这些现在都已确认成立): - 左侧栏有书本图标; - 打开面板能新建项目、能「载入示例」; - 在对话里让模型「看看我的小说项目」,它能调到 `novel action=list`。 如果面板是一片空白或「读取失败」,说明 Host 半没挂上——先确认重启做了,然后点面板里的「刷新」。 --- ## 六、v1.0.0 修复了什么 逐条都能用 `node scripts/verify.mjs` 复现: 1. **启用插件后 DSH 直接起不来(最致命)**:Host 半读取 `ctx.Service` 当服务基类,但 `Service` 既不是可注入的服务、也不是白名单 verb,cordis 的注入代理直接抛错,而且这个错会让**整个 DSH Host 退出**,不是只停掉本插件: ``` dsh-plugin-desktop: fatal load failure: Error: cannot get property "Service" without inject ``` 也不能改成 `import { Service } from '@deepseek-ai/cordis'`:本插件以 link 方式装进 profile,`@deepseek-ai/*` 从它下面解析不到(实测 `ERR_MODULE_NOT_FOUND`)。正确做法是 `ctx.provide(命名空间, 实例)` —— 这正是 `Service` 构造器内部调用的同一套注册机制。已改为 `ctx.provide`,并新增「用真实 Cordis Context 挂载插件」的回归测试。 2. **Remote 方法描述符位置错误**:`remoteMethods()` 从 `Object.getPrototypeOf(service)` 读取描述符,所以方法和描述符必须挂在**原型**上而非实例上。现在用类原型承载,与真实 `Service` 子类的布局一致。更早的符号键写法(导致 `remoteMethods()` 返回空数组、所有按钮报 `gateway/invocation-unavailable`)也已修正。 3. **`novel` 工具的 JSON Schema 非法,导致每一轮对话都失败**:`ctx.tools.register` 把 `parameters` **原样**发给模型服务商,不做编译。原来传的是一个“参数名 → 定义”的映射表,而不是 JSON Schema —— 于是名为 `title` 的参数正好落在元模式期望字符串关键字 `title` 的位置,服务商直接拒绝: ``` Invalid schema for function 'novel': {"type":"string","description":"create 或 appendChapter 的章节标题。"} is not of type "string" ``` 这个错误会让**整轮对话**在模型开口前就死掉。现在加了 `parameterJsonSchema()`,编译成正规的对象根 schema(`type/properties/required`),并新增 `scripts/check-tool-schema.mjs` 按服务商的规则校验。 4. **板块内容会串位**:`normalizeProject` 补齐缺失板块时按**下标**回退匹配,导致 `core` 的正文被复制到 `world`。改为只按 id 匹配。 5. **自动保存永远不触发**:防抖计时器依赖整个 `project` 对象,每敲一个字就重置,永远不会到点;且保存闭包持有的旧快照会覆盖新输入。改为用 ref 读取最新文档 + 版本号防止旧快照回写,并在切换/关闭时补存。 6. **写正文会改错章节状态**:编辑正文时把章节状态强制设成 `writing`,导致已定稿的章节被静默改回。现在改正文只改正文。 7. **「写入对话」按钮从没生效**:原来查找的 `ctx.get('input')` 是每会话的 Hook 源,不是那个服务。改用 `conversation.input.shell(sessionId).actions.setDraft`。 8. **读取失败时面板像空的一样**:现在显示明确的错误卡片 +「刷新」+ 提示「Host 半可能没挂载」。 9. **主题变量名不存在**:CSS 里 4 个 token 和 1 个字面颜色不存在,悬停态会变透明。已换成真实 token 并加审计脚本。 10. **项目 id 依赖巧合**:改为显式生成 `novel-`。 11. **无效的 try/catch**:包裹 `ctx.slots.inject(...)` 的 try/catch 永远捕获不到注册错误,已移除。 12. **`list` 的结果文案是错的**:`list` 返回的对象里没有 `brief`,于是落进了通用分支,输出成「小说项目已更新:projects=[]」—— 一次纯读取却声称“已更新”,还把原始 JSON 丢给模型。这个 bug 所有离线测试都没抓到,因为它们只断言返回的**对象**,从不断言渲染出的**文本**;只有真正调用一次工具才会暴露。已为 `list` 加专门分支(空库提示怎么新建、有项目时列书名/项目 id/进度),并在 `simulate-tool.mjs` 里加了渲染层断言。 13. **点开面板是空白(被皮肤挡住)**:装了皮肤时,点侧栏书本图标切换过去,右侧只剩背景插画,面板内容完全看不见,而且没有任何报错。 原因是 CSS 绘制顺序:皮肤把壁纸/立绘做成 `position:absolute; z-index:0` 的图层,盖在 `.centerCol` 上。**定位元素会画在普通流内容之上**,而我们的 `.nv-root` 没有定位,于是面板渲染得好好的、却被插画整块盖住。自带的对话面板躲过这一劫,是因为皮肤专门给它加了 `position:relative`。 已给 `.nv-root` 加 `position:relative; z-index:1; isolation:isolate`。**同时给面板头部加了「返回对话」按钮**:主区域是单键槽位,切走之后原本只能靠侧栏切回来,某些皮肤会隐藏那些行,人就困住了。 这个 bug 用假 `ctx` 和静态检查都发现不了(组件渲染完全正常,17 个 DOM 节点都在),所以新增了 `scripts/check-panel-render.mjs`:用极简 React 真实执行面板渲染与副作用,并断言根节点必须是定位 + 提升层级。 14. **面板读不到数据:`ctx.remote.novels` 是 undefined**:撞出插画层之后,面板显示「读取失败:Cannot read properties of undefined (reading 'list')」。 原因:Client 的 `ctx.remote` **不是**按 Host 服务动态生成的。它由 DSH 构建时生成的 contribution 集合装配(`@deepseek-ai/dsh-api-remotes` 里那一串 `TYPERT_REMOTE*` 常量),第三方命名空间根本不在里面。运行时补救的办法 `ctx.remote.$mount(contribution)` 也走不通 —— 它要求 `codec.mode === 'strict'` 的编译期生成 codec,手写不出来。 正确通道是网关自己用的那条:`ctx.connection.rpc.call('/api', '/', { args })`,返回 `{ ok: true, value }` / `{ ok: false, error }`。已加 `remoteCall()` 辅助函数替换全部 9 处 `ctx.remote.novels.*` 调用,并从 `inject` 里去掉了不再需要的 `remote`。Host 端要求载荷**恰好**只有 `args` 一个字段、且参数名与 Host 方法签名一致(`id` / `value` / `title`),这些都已对齐。 15. **主按钮变成一块没有文字的蓝色方块**:板块页底部「新增问题」右边那个蓝色块,其实是「交给模型逐个提问」按钮 —— 文字**完全看不见**。 原因是主按钮那条规则把**背景 token 当成了文字颜色**: ```css .nv-btn[data-primary="true"]{background:var(--dsw-alias-brand-primary); color:var(--dsw-alias-bg-base)} /* ← 隐形字 */ ``` 皮肤会把 `--dsw-alias-bg-base` 设成 `transparent`(本皮肤就是),好让插画透出来。于是按钮有了暗蓝底色、文字却是透明的,看起来就是一个空色块。 改为用品牌色**描边**而不是填充:`.nv-btn[data-primary]` 现在是透明底 + 品牌色边框 + 品牌色文字 + 加粗。这样文字始终落在正常文字 token 上,任何皮肤、明暗两种主题下都读得清。顺带把文案缩到「交给模型逐个提问」,并加了 `title` 提示;问题列表为空时不再显示这个按钮(否则会把一条没有问题的提问复制进草稿)。 新增守卫:`audit-css.mjs` 现在会扫描 `color:` 声明,**禁止把背景类 token 用作文字颜色**;并由 `self-check-css-audit.mjs` 证明这条守卫真的会报错(拿旧规则做坏样本验证)。 排查过程中还修掉了一次工具误操作:用 PowerShell 的 `Set-Content -Encoding utf8` 改写源文件时把全部中文双重编码了,已逐行恢复,现在所有源文件都是无 BOM 的干净 UTF-8。 > **教训**:离线模拟全部通过,却先后漏掉了五个致命问题 —— 因为它们用假的 `ctx` 对象(漏掉 `ctx.Service` 导致的 Host 崩溃)、只检查返回对象(漏掉 `list` 的错文案)、只验证 schema 形状不看服务商规则(漏掉 `title` 关键字冲突)、只看渲染结果不看绘制层叠(漏掉被皮肤整块盖住)、用自造的 `ctx.remote` 桩而不核对真实装配(漏掉命名空间根本不存在)。现在的对策分别是:真实 Cordis Context 挂载、渲染层断言、按服务商规则校验 schema、极简 React 真实渲染 + 层级断言、按真实 carrier 协议(`connection.rpc.call`)调用并在假 ctx 里复刻同一信封。 > **Host 半不热重载**:改完 `lib/index.js` 必须完全重启 DSH Desktop 才生效(实测 HMR 不会接管,改动后工具返回的仍是旧文案)。Client 半(面板界面)改动后刷新页面即可,不需要重启 Host。 --- ## 七、v1.0.1 修复了什么(测试报告的 P0/P1) 依据独立测试报告《dsh-novel-companion 测试报告与改进方案》,逐条修完 P0/P1,完整列表见 [CHANGELOG.md](CHANGELOG.md)。最要紧的是三条 **读写契约** 问题: 1. **`get` 只把目录交给模型(P0)**:原先 `renderNovelResult` 只认 `brief`,模型读到的是一百来字的目录,真正的设定与正文被丢掉,于是它只能凭记忆和想象写。现在 `get` 把 6 个板块全文、全部大纲和有预算的正文交给模型,另加 `chapterId` 按章精确读取。 2. **面板旧快照覆盖模型刚写入的内容(P0)**:文档加 `rev` 乐观锁,旧副本被拒绝并自动刷新;`刷新` 按钮同时刷新文档与列表。自动保存的防抖也改为每次编辑都重排计时器,并吸收新 `rev`,不会再「存着存着就永久失效」。 3. **失败被渲染成「小说项目已更新」(P0)**:失败返回 `ok:false` 并渲染成「小说项目操作失败:…」,模型不会把写错 `blockId` 当成写成功继续往下写。 另外修了:第 501 章静默丢弃(现在显式报错、提示拆卷)、20 万字单字段静默截断(现在拒写并提示拆章)、`list()` 每次自动保存全量搬书库(改为只返回索引,负载 −83%)、`readiness()` 死代码(阶段动作返回就绪度,面板显示「建议先补齐:…」)、`outline` 动作忽略 `status=outline`、「载入示例」可重复导入、i18n 重复键与 11 处硬编码中文。 工程侧同时补上:`scripts/check-client-fresh.mjs`(`lib/client.js` 落后于源文件时直接失败,不再被「重新生成」步骤掩盖)、GitHub Actions CI、`package.json` 的 `engines` / 脚本 / 版本号与 CHANGELOG,以及面板章节三态与 `addChapter` 的 StrictMode 副作用。 > 报告里更远的路线图(按块/按章写入的细粒度 Remote、Host 写入后广播事件、版本快照与导出/导入、全文搜索与按章导出等)**尚未实现**,已记录在报告第四节。 --- ## 八、v1.0.2 改了什么 **去掉了对话输入框上方的「小说 · 发设定 / 写正文」浮条。** 它挂在 `conversation.input.dock` 槽位上,只要装了插件就会出现在**每一个会话**里,不管作者有没有在写小说;而且它做的三件事面板里都已经有了,属于重复入口。小说相关的东西现在只出现在「小说」面板里: - 发设定 → 面板标题卡片里的「复制到会话草稿」; - 写正文 → 章节编辑区的「按定稿大纲写正文」; - 审读 → 章节正文下方的「请模型审读这一章」。 `scripts/check-panel-render.mjs` 的断言也一并反转:现在会检查**不得**注册 `conversation.input.dock`,防止浮条被误加回来。Client 半改动刷新页面即生效(Host 半没动,不需要重启)。 --- ## 九、自检脚本 ```bash node scripts/verify.mjs ``` 17 项检查,全部在不启动应用、不启用插件的前提下运行。其中依赖本机 DSH Desktop 安装的几项(真实 Cordis Context、Remote 契约、模块解析)如果找不到安装,会打印 `SKIP …` 并计入“跳过”,不会误报失败;用 `DSH_HOME` / `DSH_PROFILE` / `DSH_APP_ROOT` 可以显式指定安装位置: | 检查 | 内容 | | --- | --- | | **Client bundle freshness** | 比较 `lib/client.js` 与 `client.js.tpl` + `state.js` 的生成结果;落后就直接失败(旧版这里只是「重新生成」,永远通过) | | build the Client half | 把 `client.js.tpl` + `state.js` 合成 `lib/client.js` | | Host / Client syntax | `node --check` | | Host invariants | 字符串键描述符、**禁止读取 `ctx.Service`**、不 import cordis、服务经 `ctx.provide` 发布等 | | **Tool JSON Schema** | 按服务商的规则校验 `novel` 工具的 schema(对象根、`required` 提升、无关键字冲突、无乱码) | | **Host activation** | 用**真实 Cordis Context** 挂载插件:fiber 转为 active、`ctx.novels` 可解析、网关发现 6 个方法、`create/list/get/save` 往返存储 | | **Host mount/unmount** | 挂载→卸载无异常、存储域关闭、服务与工具正确注销 | | **Panel render** | 用极简 React 真实执行面板渲染,断言根节点定位 + 层级提升(防止被皮肤插画层盖住) | | Client ↔ theme tokens | CSS 只用真实存在的主题变量、无字面颜色、括号平衡 | | **CSS audit self-test** | 用坏样本证明上面的审计守卫真的会报错(守卫不能永真) | | Remote contract | 用**真实** Typert 包验证 6 个方法可被网关发现 | | Module resolution | 用**真实**桌面端解析器验证按包名能解析 Host 半 | | Shared validator | `normalizeProject` 的 15 条回归(含串位 bug) | | Host self-test | 29 条存储/缓存断言 | | Panel simulation | 26 条“面板 → 网关 → Remote → 存储”全链路 | | Tool action simulation | 41 条 `novel` 工具动作与渲染断言 | 单项也可以单独跑,例如: ```bash node scripts/check-client-fresh.mjs node scripts/audit-css.mjs node scripts/check-tool-schema.mjs node .selftest/prepare.mjs && node .selftest/run.mjs ``` `scripts/restart-and-check.ps1` 用于“重启 DSH Desktop 并自检”,路径自动探测,也可用 `-Exe` / `-LogDir` 覆盖。 --- ## 十、目录结构 ``` dsh-novel-companion/ ├── package.json # bundle 声明 + dsh.client + engines/scripts ├── cordis.patch.yml # 插入 novel-companion 一行 ├── icon.svg ├── locale/{zh,en}.json # 插件页标题与简介 ├── CHANGELOG.md # 版本变更记录 ├── .github/workflows/ # CI:Windows + Ubuntu、Node 20/22 跑 verify.mjs ├── docs/对话纪要.md # 这个插件是怎么一步步做出来的(开发记录) ├── lib/ │ ├── index.js # Host:存储域、Remote 服务、novel 工具、提示词 │ ├── demo.js # 示例项目(雾港钟楼) │ ├── state.js # 两半共享的纯逻辑:校验、提示词生成、markdown │ ├── client.js.tpl # Client 半源码(唯一真源) │ └── client.js # 由 tpl 生成,勿手改 ├── scripts/ # 构建与验证脚本 │ └── lib/env.mjs # 定位仓库根目录与 DSH 安装(支持环境变量覆盖) └── .selftest/ # 存储桩与断言(index/state/demo 等由 prepare.mjs 生成) ```