# 交接文档(HANDOFF)—— 在新会话里继续开发 > 目标读者:**没有任何上文的新会话**。读完这一份即可继续改这个插件。 > 快照:2026-09-12 · v1.4.0 · commit 见 `git log` > > **⚠️ v1.15.0:本插件的本地生图链路(ComfyUI)已完全移除。** 出图改由宿主的 `generate_image` / `edit_image` 承担, > 插件侧只保留「图片的入口与复用」(`rp_assets` 的 `list`/`get`/`tag`/`import`、立绘位、玩家导入图片); > 工具面 11 → **8**(删 `rp_styles` / `rp_illustrate` / `rp_scenes`),路由 29 → **25**,设置页与面板不再有风格库 / 生图配置。 > 本文与生图、风格库、设置页/面板相关的段落已按新现实改写;§6 里涉及 ComfyUI 内部的条目是**历史记录**(已标注)。 > 权威来源:`docs/REMOVE-IMAGE-GEN.md`、`docs/STATUS.md` §7 变更历史。 --- ## 0. 一句话现状 插件**已可用且已实测**:8 个 `rp_*` 工具、会话级战役配置、世界书、状态追踪、 角色卡 8 字段、设置页与右侧栏 DM 面板,以及**PNG 故事书(角色卡)导入**—— 在工作区那一行点「📖 导入 PNG 故事书」→ 选卡 → 自动切 dm 预设 + 写世界书 + 开场。 (~~10 个工具 / 10 种内置风格 / 本地出图~~ —— **1.15.0 移除生图**:出图交给宿主的 `generate_image` / `edit_image`,插件只管图的入口与复用。) **架构已收敛为「一切都在 dm 预设作用域」**:host 组合里**不注册任何模型工具**, 全部 8 个工具(含 `rp_random`)与两条提示词注入通道都由 dm 预设的 `rp-bridge.mjs` 在 agent 作用域注册 —— 非 dm 会话既看不到工具,上下文里也不会出现跑团内容。 冒烟测试**全绿(0 失败)**:`tools/smoke-dm.mjs` 1004 条 + `tools/smoke-client.mjs` 427 条 + `tools/smoke-card.mjs` 256 条 + `tools/verify-roundtrip.mjs` 10 条。 **待界面确认**:右栏 RP 面板与故事书导入面板的实际渲染(见 §5.1 / §5.2)。 --- ## 1. 关键路径 | 用途 | 路径 | |---|---| | 源码(权威) | 本 checkout(写这份快照时的路径是 `D:\Code\dsh\rp-tools-plugin`,**已改名**;实际以你 clone 到的目录为准,如 `D:\code\dsh\dsh-rp-tools`) | | GitHub | `https://github.com/SiriusWJ/dsh-rp-tools`(public,分支 `main`,topics 含 `dsh-plugin`) | | 安装位置(profile) | `~/.dsh/profiles/web/node_modules/dsh-rp-tools`(`file:` 依赖 = **安装期拷贝,不会自动跟随源码**) | | 数据目录 | `~/.dsh/data/dsh-rp-tools/`(`styles.json`(**1.15.0 起只剩卡库根 + 默认宏列表**;旧的 `styles` / `negative` / `comfyui` / `imageSizes` / `defaultStyle` 键可能还在盘上,但已不再读) / `sessions/.json` / `dm-sessions.json` / `_agent-probe.json` / `_standing-probe.json`) | | 世界书(**按会话隔离**) | `<会话工作区>/rp-sessions/<会话 id>/rp-worldbook.md` —— 工作区取自 `session.header.cwd`(如 `D:\Story`)。老版本在工作区根目录,首次读取时会**一次性迁移**一份过来(旧文件保留) | | PNG 卡库(导入源) | `D:\Story\sillytavernassets`(3269 张,`cards/<分类>/*.png`)—— 设置页「卡库目录」可改,存 `styles.json` 的 `cards.root` | | 导入产物(按会话) | `<会话工作区>/rp-sessions/<会话 id>/cards/.{md,json,png}`(卡全文 / 规范化结果 / 卡面) | | 卡库目录 | `cards.root`(设置页);**留空 = 会话工作区下的 `rp-cards/`**。不再有任何固定路径兜底,也不再有私有索引 | | dm 预设 | `~/.dsh/.agent-presets/dm/agent.cordis.yml`、`~/.dsh/.agent-presets/dm/session-filter-v2.mjs`、`~/.dsh/.agent-presets/dm/rp-bridge.mjs`(仓库内 `preset/` 有同名副本,**权威仍在预设目录**) | | ~~ComfyUI~~ | **与本插件无关了(1.15.0 移除本地生图)**。历史记录:Comfy Desktop **0.35.0** · `http://127.0.0.1:8188` · RTX 5080 16GB | | ~~模型~~ | **与本插件无关了(1.15.0 移除本地生图)**。历史记录:`E:\AI\Models\models\{diffusion_models,text_encoders,vae,loras}`(`Documents\ComfyUI\models` 是指向它的 junction) | | 重启器 | 计划任务 `dsh-rp-restart` → `D:\Code\dsh\comfyui-workflows\rp-restart.cmd`(延迟 75 秒后 POST dsh-restart-btn 的重启接口;目录名里的 `comfyui-` 只是历史命名,与生图无关) | --- ## 2. 当前功能(8 个工具,**全部只在 dm 预设作用域**) | 工具 | 作用 | |---|---| | `rp_random` | 骰子 / 区间 / 加权抽取 / 布尔;`seed` 可复现 | | `rp_character` | 角色卡增删查(8 个字段,含 `first_mes` / `mes_example` 两个**样本字段**);`appearance` 是配图时保持一致的主要手段(~~立绘生成~~ **1.15.0 移除**) | | `rp_state` | 状态追踪:场景 / 时间 / 地点 / 在场 / 线索 + 队伍(状态·持有·伤病·目标)+ 自由旗标 | | `rp_lore` | 世界书:`list` 目录 / `find` 按条取 / `template` 生成模板(在会话工作区) | | `rp_session` | 本会话配置:世界 / 提示词前缀 / 战役名 / DM 设定 / 宏(~~会话默认风格 / 风格备注~~ **1.15.0 移除**) | | `rp_assets` | 本会话**图片资源库**:`list` / `get` / `tag` / **`import`**(把宿主 `generate_image` 出的图、或玩家导入的图收进库,之后按 `kind`/`characters`/`tags`/`q` 查出来复用) | | `rp_config` | **全局**卡库配置:卡库根目录 + 默认宏列表(~~负面词 / 全局默认风格 / ComfyUI 地址 / 单风格字段~~ **1.15.0 移除**) | | `rp_table` | 随机表(表名 + 骰式 + 条目)定义与掷表 | ⚠️ **全部 8 个都在 dm 预设作用域**(连 `rp_random` 也是)—— 由 `rp-bridge.mjs` 调 `registerRpTools(ctx)` 注册。**host 组合里一个模型工具都不注册。** dm 预设的 `keepGlobalTools` 白名单现在有 **5 项**:`render_ui` / `validate_dsh_ui` / `web_search` / **`generate_image`** / **`edit_image`** —— 后两个是宿主 `dsh-image-gen` 的**全局**工具,而 dm-filter 会 deny 掉所有没被放行的全局工具,所以**必须写进白名单**;否则 persona 里那句「用 `generate_image` 配图」 就是一句空话(模型看不到工具)。 早先 `rp_random` 是全局的,为此白名单还得放行它;现在它也在 dm 作用域了。 **界面**:设置页「RP工具」(全局卡库配置:卡库目录 + 默认宏列表,外加工具列表:只列名字与一句话说明);DM 会话头部**右上角**「🎲 RP」按钮 → 把 RP 面板作为**右侧栏页签**打开(DM 设定 / 世界设定 / 宏变量 / 世界书 / 角色卡 / **资源库** / 备份·会话包 / RP 表格 / 当前状态),右栏自带收起与浮动。**工作区那一行**(`conversation.input.dock`,id `rp-card-import`,order 15)还有「📖 导入 PNG 故事书」入口 —— 见 §3 ⑩。 **~~风格库~~(1.15.0 移除)**:本地生图链路整条删除时,风格库、LoRA 下拉(`/rp-tools/loras`)、 全局负面词、全局默认风格与单风格字段一并删掉了 —— `styles.json` 里那几个旧键也不再读。 设置页现在只剩**卡库目录 + 默认宏列表**;「配图风格」改由 DM 直接拼进 `generate_image` 的 prompt (预设 persona 里给了几组可选风格词)。 --- ## 3. 架构要点(改代码前必读) **① 宿主半侧拆成两个入口**(`lib/index.js`): | 入口 | 作用域 | 做什么 | |---|---|---| | `apply(ctx)` | 全局(host 组合) | **不注册任何模型工具**。只注册 25 条 HTTP 路由(设置页 / 客户端 / 卡库 / 资源库 / 会话包)+ 监听 `session/created`(fork 继承配置、记录工作区)。 | | `registerRpTools(ctx)` | agent(由 dm 预设的 `rp-bridge.mjs` 调用) | 注册**全部 8 个** `rp_*` 工具(每个的 `execute` 包一层 `markDmSession` 保底登记)+ 挂两条提示词注入通道。 | 各自的依赖声明: ```js // lib/index.js export const inject = []; // 全局:连 tools 都不需要(路由走 ctx.inject(['webServer'])) // preset/rp-bridge.mjs export const inject = ['tools', 'systemPrompt']; ``` **为什么注入必须放在 agent 作用域**(这是踩过的架构错误,别改回去): `system-prompt/assemble` 是**按作用域过滤**的事件(契约原文:*scoped listeners receive only that scope's assemblies*),所以在 agent 作用域注册时,回调**只会收到本会话的装配** —— 「只在 DM 会话生效」与「会话隔离」都由 Cordis 免费保证。 早先版本把它注册在全局 `apply()` 里,还为此写了一套「猜当前会话」的启发式 (记 `session/created`、按会话文件修改时间挑、可手动指定优先)。两个错: ① 全局注册让**每个会话**都带上跑团世界观; ② 猜会话**猜错时会把别的战役设定注进来**(实测:把 9 角色无世界的会话当成目标,而不是 861 字世界的那个)。 现在这些代码**全部删除**,会话 id 直接取作用域自带的 `ctx.agent.id`。 回归测试:`tools/smoke-dm.mjs` 里「全局不注册任何模型工具」+「注入只由 agent 作用域注册」。 **② ~~生图链路~~ → 配图链路(1.15.0 改)**:~~`buildWorkflow(style, …)` 生成 API 工作流 → `POST {baseUrl}/prompt` → 轮询 `/history/{id}` → 图片经插件自己的同源代理 `/rp-tools/media` 返回~~ —— **这套已整条删除**,插件不再和 ComfyUI 说话(`DSH_RP_COMFY_URL` / `DSH_RP_COMFY_ORIGIN` 两个环境变量也一并没了;`DSH_HOME` 与 `DSH_RP_PROFILE_PACKAGE` 还在)。 现在:DM 直接用宿主自带的 **`generate_image`**(改图 `edit_image`)出图 → 图**自动作为附件挂在对话里**(工具结果带 image 内容块,模型拿不到可用的 `src`,所以新图**不必**再用 `dsh-ui` 的 image 组件贴一遍)→ 想把图留起来复用,就调 `rp_assets(action:"import", path:"", label, tags, kind)` 收进本会话资源库;**只有从资源库重放的图**才需要用 image 组件显示。 **③ 会话 id 归一化**(关键,刚修):工具侧 `exec.agent.id` 形如 `session-`,而会话目录 / 客户端 `useSessions().current` 是裸 ``。`normalizeSessionId()` 统一剥掉 `session-` 前缀,`loadSession` 兼容旧文件名,`isDmSession()` 两种写法都能命中。 **④ 客户端三处贡献**(`client/client.js`): - `settings.section` id `rp-tools` → 设置页卡片; - `conversation.session.header.utilities` id `rp-tools` → 会话头部**右上角**的「🎲 RP」入口(只在 DM 会话渲染,非 DM 返回 `null`); - `sidebar.right.pane.tab` / `sidebar.right.pane.tab.title` key `dsh-rp-tools` → **RP 面板本体,作为右侧栏页签**。 **面板为什么在右栏而不是浮层**:早期版本用 `shell.overlay` 自己画浮层(ComfyUI 面板当年也是这个做法,这里只作历史对照), 既丑又不能收缩。现在改为注册成右栏的一种页签,**收起 / 浮动 / 关闭 / 拖拽全由 DSH 右栏负责**,插件不再自己管定位。 接线三件套(缺一不可): ```js // ① 类型的静态面(kind 是 openTab 的入参;id 是 body/title 两个座位的 key) ctx.inject(['sidebarRightTabs', 'sidebarRight'], (injected) => { injected.sidebarRightTabs.register({ id: 'dsh-rp-tools', kind: 'dsh-rp-tools', title: () => '🎲 RP', guide: [{ order: 30, title: () => '🎲 RP 跑团面板' }] }); // ② 页签里的面板主体 injected.slots.inject('sidebar.right.pane.tab', () => injected.slots.register( { name: 'sidebar.right.pane.tab', key: 'dsh-rp-tools' }, (props) => h(RpSidebarTabBody, props))); // ③ 右栏条上的小标题 injected.slots.inject('sidebar.right.pane.tab.title', () => injected.slots.register( { name: 'sidebar.right.pane.tab.title', key: 'dsh-rp-tools' }, () => h(RpTabTitle))); }); ``` 打开动作:`ctx.sidebarRight.openTab('dsh-rp-tools')` —— **它会顺带展开右栏**(官方原话: 「`openResource` 与 `openTab` 是导航控制器,进入这一列的每一条路都是对它们之一的调用」), 不需要自己再调 `layout.openRightbar()`。 ⚠️ 导航必须用**注入了 `sidebarRight` 的那个 ctx**(延迟注入回调的参数),不能用 `apply(ctx)` 的根 ctx, 否则 `ctx.sidebarRight` 是 undefined。 判定 DM 会话的顺序(**改这里之前先读完**): 1. **权威**:`props.useSessions((s) => s.byId[sessionId]?.projectionValues?.agentPreset) === 'dm'` → 是 DM,直接渲染,并(每个会话一次)POST `/rp-tools/dm-mark` 把判定落到宿主。 2. **回退**:预设还没投影出来(旧会话 / 刚切过去)→ 查 `/rp-tools/session?sessionId=` 的 `isDm`。 3. 两个都说不清 → `return null` / 提示「不是 DM 会话」。 会话 id 取法:`[props.useSessions(s => s.current), props.sessionId, props.session?.id]` 里第一个**非空字符串**。 ⚠️ 别写成 `String(fromHook || props.sessionId || '')`:钩子初值是 `undefined`、切换期可能是**空串**, 空串会把后面的兜底全短路掉 → `isDm` 永远 false → **入口静默不出现**(这正是最难查的那类 bug)。 槽位契约实测(`cordis_inspect_query` Slots)确认 `standardProps` 同时含 `useSessions` 与 `sessionId`; `useSessions` 的 store 是 `@deepseek-ai/dsh-api-session-controller` 的 `list`,`current` 存**裸 id 字符串**。 (为什么不用 `conversation.view` 页签:它**无法按会话条件注册**,一注册就所有会话都出现。) **⑤ 全局 vs 会话**:全局 = 卡库根目录 + 默认宏列表(`styles.json`)—— ~~风格库 / ComfyUI 地址 / 负面词 / 全局默认风格~~ **(1.15.0 移除生图时一并删掉)**;会话 = 世界 / 角色卡 / 资源库 / 随机表 / 提示词前缀 / 战役名 / DM 设定 / 宏(`sessions/.json`)—— ~~会话默认风格 / 风格备注~~ **(1.15.0 移除)**。 **⑥ fork 分叉要继承会话配置**(`copyRpSessionFromParent()` + `session/created` 监听)。 RP 配置按会话 id 存,而 fork 出来的是**新 id** —— 不处理的话用户分叉后世界/角色卡/随机表全「消失」。 谱系信息 DSH 自己给,全在 `session.header` 上: - `parentSession` = fork 来源会话 id; - `isSeeded` = 该日志含 fork 继承的事件前缀(**resume 是 false**,所以重启恢复不会被误判成 fork); - `agentPreset` = 会话用的预设(判定 DM 用得上)。 判定条件:`header.parentSession && header.isSeeded`。复制是**快照**语义(父子之后各改各的); 子会话已经自己写过配置就不覆盖。注意这是宿主半侧改动,**要重启才生效**。 **⑦ 两条提示词注入通道**(`installStandingPrompt()`,**只在 dm 作用域注册**): | 通道 | 段名 | order | 放什么 | |---|---|---|---| | system 段 | `rp:standing` | 210 | 战役名 / 世界设定 / **角色索引**(逐字节稳定) | | runtime context | `rp:turn` | 20 | 当前状态 + 世界书命中 + **在场角色的详细卡**(每轮不同) | - **order 210 是刻意的**:工具说明占 100–199,稳定骨架放其后,即使骨架抖动,稳定的工具前缀仍能命中前缀缓存。 - **未配置时写固定短文案,而不是把段删掉** —— 避免段布局抖动打穿缓存。 - 内容必须**逐字节稳定**:不掺时间戳/随机数/轮次号。常驻内容放 standing、每轮变化的放 context。 - **角色卡分两层注入**:standing 只放「名字 + 一句简介 + 是否常驻展开」的**索引** (`renderCharacterIndex`,字节数不随角色数增长);详细卡片进 turn 通道,且**只在角色出场的那几轮** (`charactersToExpand`:名字出现在最近对话里,或被标了 `always`)。 - **宏表(按会话隔离)**:会话配置里的 `macros: { user: '阿岚', place: '广寒宫' }`。 - 值在**第一次导入时由界面问用户**(默认取全局「玩家称呼」,可改,也能加自定义宏); 预览接口 `GET /rp-tools/card` 会返回卡里扫到的宏名(`discoverMacros`+`collectCardText`)供预填; - RP 面板有「宏 / 变量」卡片,任何时候都能改;`rp_session(action:"set", macro_name, macro_value)` 让 DM 也能设; - 注入时由 **`systemPrompt.variable(name, provider)`** 在 agent 作用域逐名注册(`macroVars`/`macroRegistrars`/`macroValueCache`), 所以世界设定 / 世界书条目里写的 `{{x}}` 会**跟着面板改的值变**(不是把值烤进文件); - 未注册的宏由 `neutralizeMustache(text, known)` 换成全角 —— 宿主对未知变量是**严格**的(直接抛错); - **自动宏**(`time` / `date` / `datetime` / `weekday` / `isotime` / `localtime` / `timezone`):永远注册、值在装配时现算, 导入界面把它们列出来并标「自动」(不用填);用户也可以填个固定值把它**钉死**(例如游戏内时间「子时三刻」)。 注意:写进 standing 段的自动宏每轮都变,会打穿前缀缓存 —— 放进世界书条目(走 turn 通道)没这个问题。 - `{{char}}` 仍是**导入时展开成卡名**:一场戏可能多角色,全局变量表达不了它。 - ⚠️ 注册必须发生在**该会话的 agent 作用域**;路由(全局作用域)改完宏表要通过 `refreshSessionMacros` 回调进去, 否则要么污染别的会话,要么下一轮装配因未知变量抛错。 - **身份宏 `{{user}}`**:是宏表里的一个普通键,默认值取全局「玩家称呼」; (DSH 原生的插值机制,语义上等价于酒馆那边的「身份宏」),所以用户在**世界书条目 / 世界设定里手写**的 `{{user}}`(哪怕是我们导入之后才写的)也会被正确替换成玩家称呼(设置页「玩家称呼」可改)。 `neutralizeMustache()` 因此要**放行注册过的变量、只中和没注册的宏** —— 宿主对未知变量是**严格**的(直接抛错)。 `{{char}}` **没有**全局注册:一场戏可能有多个角色,全局变量表达不了它;导入时按卡名展开仍走 `resolvePlaceholders`。 - **其余 `{{…}}` 必须中和**(`neutralizeMustache()`):宿主会对 section 做变量插值, 未注册的写法会被当未定义变量**抛错**。 **⑧ 状态追踪**(`rp_state` + `applyStateUpdates` / `renderState`): 解决的不是「记不住上一幕」(那在上下文里),而是**只有叙述文本承载的「变化」会在上下文压缩后消失** —— 伤势、东西在谁手里、关系转变、伏笔。 - 字段:`scene/time/location/present/clues` + `party[]`(character/status/inventory/conditions/goal)+ 自由 `flags`。 - **空串即清除**(先删后设),不留幽灵键 —— 伤势好了、东西用掉了能真的清掉。 - 注入在 turn 通道**最前**(最权威);`FLAGS_MAX_SHOWN` / `FLAG_VALUE_CHARS` 是成本护栏。 - 更新靠**模型主动调工具**,不解析叙事正文(解析自由文本很脆)。 - `renderState` 会在状态多轮未更新时附一句提醒(防静默漂移);测试断言渲染结果**绝不含 `undefined`** (曾经因 `clues` 漏配显示名而输出 `undefined:…`)。 **⑨ 世界书**(`parseLoreMarkdown` / `activateLore` / `renderLore`): 条目存**会话工作区根目录的 `rp-worldbook.md`**(路径来自 `session.header.cwd`)。 用户能用任何编辑器改,DM 也能用 read/write 维护,还能用 `rp_lore(action:"template")` 一键生成模板。 解析规则:**`##` 开新条目**(单个 `#` 是文档标题,不参与解析);``; 不写 keys 就用标题当触发词。 激活语义(刻意比参考实现简单):常驻无视关键词;其余**子串匹配**(中文场景不做整词匹配); `prob < 100` 用**确定性掷点**(种子 = `会话:轮次:条目名`,用 `session.seq` 当轮次), **复用 rp_random 的同一套 PRNG**(`hashSeed` + `mulberry32`),所以可精确回放; 预算按「常驻优先 → order 降序」取,**被裁的进 `dropped` 并列标题**(不静默丢弃)。 刻意**不做**:inclusion group 随机竞争、sticky/cooldown/delay 定时器、多来源分层 —— 都是参考实现花大代价的部分,与「轻量」定位不符。 **面板里各字段由谁编辑**(改界面时注意): - 面板直接给了输入框的:世界设定 / DM 设定 / 宏变量 / 角色卡 / 资源库 / 随机表 / 当前状态。 - **只由 DM 用 `rp_session` 工具维护、面板不放输入框的**:提示词前缀 / 战役名。 面板保存时把这些字段**原样回写**(`save()` 里送的是 `draft.campaign`), 所以精简界面**不会**清掉 DM 已经设好的值 —— 别为了「干净」改成发送空串。 - ~~生图配置卡片(会话默认风格)~~ 与 ~~全局负面词~~ **(1.15.0 随本地生图一并移除)**。 会话面板里不再有「生图」这一类卡片;配图相关的东西只剩**资源库**与角色卡上的立绘位 (立绘只来自**玩家导入**的图或导入卡的卡面 —— 老版本那条「生成出来的立绘」已不再被读取)。 **⑩ PNG 故事书(角色卡)导入**(`lib/card-png.js` + `lib/card-import.js` + 4 条路由 + 客户端一个槽位) 调研数字(`docs/PNG-CARD-DECODE.md`,3269 张实测)决定了全部设计: `first_mes` **100% 是广告**(必须换成 `alternate_greetings`)、**40% 的卡正文只在 `character_book` 里** (导入主战场是世界书而不是字段)、**32% 的条目没有 keys**(不补 `constant` 就是死条目)、 单卡最大 167 万字(必须限量,其余写文件让 DM 按需 `read`)。解码细节(`tEXt`/`iTXt`/`zTXt`、`ccv3` 优先、 截断容错)见 `lib/card-png.js`,映射规则见 `lib/card-import.js` 顶部注释。 数据流(**解析全在宿主**,浏览器只拿摘要): ``` 客户端(conversation.input.dock) 宿主(lib/index.js) 打开面板 ──GET /rp-tools/cards──────────▶ 列卡库(服务端过滤 + 分页,索引 482KB 不落地) 选一张 ──GET /rp-tools/card?path=─────▶ 解码 PNG → 摘要 + 预览(不落盘) 点导入 ──POST remote.agentPresets.select(sessionId,'dm')──▶ 切预设(空白会话才允许) ──POST /rp-tools/card-import───▶ ① 世界书**追加合并**进 <工作区>/rp-worldbook.md ② 卡全文写 rp-sessions/<会话 id>/cards/.md(超预算条目的去处) ③ 规范化结果写 rp-sessions/<会话 id>/cards/.json ④ 卡面复制成 rp-sessions/<会话 id>/cards/.png ⑤ 角色卡合并/世界覆盖/立绘登记进会话配置 ◀─{lore, files, opening, stats}─┘ 开始游戏:inputActions.setDraft(opening) → submit() ``` 几条**必须保留**的设计约束: - **路径安全是硬边界**:`safeCardPath()` 用 `resolve()` + 前缀比对把路径锁在卡库根内,且只认 `.png`。 这条路由会把磁盘内容交给浏览器,不校验等于开了个任意文件读取。逃逸/绝对路径/非 png/不存在 → 400, 回归测试 4 条(`/rp-tools/card` 与 `/rp-tools/card-image` 各两条)。 - **世界书只追加、不重写**:`mergeWorldBook()` 按标题去重后把新条目**原文贴到文件末尾**。 刻意不走「解析 → 重新渲染」——那会把用户手写的注释与格式全部抹掉。导入是外来动作,不该动用户那部分。 - **卡库只由 `cards.root` / 会话工作区决定**(私有索引那条路已按用户要求整条删除;`lib/card-index.js` 也不再需要):用 **动态 `import()` + try** 拿(静态 import 一旦文件不存在, 整个插件加载失败);且**只在卡库根 == 内置默认根**时用它 —— 索引里的相对路径是相对默认根生成的, 换了根目录还用它就会列出一堆不存在的路径(踩过一次)。没有索引就退回 `scanCardDir()` 扫目录。 - **预设切换用官方接口**:`ctx.get('remote').agentPresets.select(sessionId, 'dm')`(hero 上的预设 chip 用的是同一个)。宿主对**已开局**的会话会拒绝(`agent-preset/locked`),所以那时先 `ctx.get('uiWorkspace').startSession()` 新建空白会话再继续。切不动时**不静默**:把原因显示出来,导入照做。 - **开场指令显式拦住 persona 的开场提问**:dm 预设的 persona 第一条就是「开局先问玩家世界从哪来」, 所以 `buildOpeningPrompt()` 里必须写「**不要再问世界从哪来**」,否则 DM 会先反问一句,导入的设定白导。 - **待办导入放模块级**(`pendingImport`,不是组件 state):新建会话会让**会话作用域的槽位子树重新挂载**, 组件 state 被重置,任务就永远等不到接手的那次渲染。 - **提交开场那一句**:`inputActions.setDraft(text)` → 等 `useInput(s=>s.draft)` 与目标一致 → `submit()`; 另有 1200ms 兜底直接提交(免得卡在等同步)。 - `slug` **只去掉 `.png`**,保留 `.card` 标记:卡库里 `X.card.png` 与 `X.png` 可以并存, 去掉就撞成同一个 slug,导入第二张会覆盖第一张的全文与卡面。 - 卡面同时进会话配置 `session.portraits[角色名] = { card: <卡库相对路径>, file: <工作区相对路径> }`: `card` 给界面拼 `/rp-tools/card-image` 的 URL(只服务卡库内的文件),`file` 是工作区自带的那份。 面板的立绘区因此变成「玩家导入的立绘优先,**导入卡的卡面**垫在后面」(~~生成出来的立绘~~ **已随 1.15.0 移除生图一并删除**)。 **真卡库探针**(不是单测,是手动诊断):`node tools/probe-cardlib.mjs [每类抽样数]` —— 拿本机 3269 张真卡跑列表 / 搜索 / 抽样解析 / 真导入,用来抓合成 PNG 测不到的脾气 (实测:160/160 解析成功、中位 1ms;642 条目的卡导入 25 条、全文 40 万字截断;三张卡合并出 124KB 世界书)。 --- ## 4. 开发流程(照抄即可) ```powershell # 1) 改源码:<你的 checkout>\{lib/index.js, client/client.js} # 2) 语法检查 node --check lib/index.js ; node --check client/client.js # 3) 同步到 profile(file: 依赖是拷贝,必须手动同步!) $dst = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-rp-tools" Copy-Item lib/index.js "$dst\lib\index.js" -Force Copy-Item lib/card-png.js "$dst\lib\card-png.js" -Force # 故事书导入(PNG 解码) Copy-Item lib/card-import.js "$dst\lib\card-import.js" -Force # 故事书导入(卡 → 会话配置) Copy-Item client/client.js "$dst\client\client.js" -Force # ⚠️ 改了 agent 作用域那半边(如 rp-bridge.mjs)还要同步到预设目录: Copy-Item preset/rp-bridge.mjs "$env:USERPROFILE\.dsh\.agent-presets\dm\rp-bridge.mjs" -Force # 预设组合/过滤器:`preset/*.yml|mjs` 只是仓库副本,**改完要拷回预设目录**才生效 Copy-Item preset/agent.cordis.yml "$env:USERPROFILE\.dsh\.agent-presets\dm\agent.cordis.yml" -Force Copy-Item preset/session-filter-v2.mjs "$env:USERPROFILE\.dsh\.agent-presets\dm\session-filter-v2.mjs" -Force # ⚠️ session-filter 改内容必须**换文件名**(Node 会按 URL 缓存 .mjs,进程内改动不生效), # 并在 agent.cordis.yml 里同步 `name: ./session-filter-vN.mjs`。v2 就是这么来的。 # 4) 重启 dsh web(75 秒后自动重启,避免打断当前回合) schtasks /Run /TN dsh-rp-restart # 5) 冒烟测试(宿主逻辑不必等重启就能验) node tools/smoke-dm.mjs # 1004 条断言:作用域隔离、装配注入(含会话 id 来源)、工具/路由注册、dm 判定、世界书、状态、图片资源库、卡库导入 node tools/smoke-card.mjs # 256 条:合成 PNG 解码(三种文本块 / ccv3 优先 / 截断容错)+ 广告过滤 + 世界书限量/空壳过滤 + 开场指令 node tools/smoke-client.mjs # 客户端:样式在 apply 时就注入(防 FOUC 回归)、槽位注册(含 id/order)、bundle 工厂可跑 # ★ 数据隔离:smoke-dm.mjs 把 DSH_HOME 指向临时目录,跑完就删 —— 绝不碰真实 ~/.dsh/data。 # (早期版本直接写真实数据目录,测试记录混进真实会话登记表,清理时极易误删 # 真实会话 —— 已经被这个坑咬过一次,别再改回去。) # 依赖从 profile 解析(同 rp-bridge.mjs 的 createRequire 办法),所以要在仓库根跑。 # ★ **测试桩必须照着宿主的真实契约写**:1.11.0 修的那个「测试全绿、生产零注入」 # 就是桩凭空给了 `ctx.agent`(真实 host 没有)+ waterfall 的 `next` 写成带参。 # 改桩之前先回去读宿主源码(dsh-agent 的 assembleContextFor / dsh-system-prompt 的 assemble)。 # ★ 断言要覆盖「真的能跑」,不只是「定义正确」:曾经删掉全局 tools 数组后漏改 # /rp-tools/tools 里的引用,路由直接 400,而当时 140 条断言全绿 —— # 因为它们只测工具定义与纯函数,从没真的打过路由。现在有 7 条「路由体检」。 # ★ 故事书导入另有两层:① 闭环断言(导入的世界书真的按触发词进 buildTurnContext, # 进常驻段的是世界设定、角色进索引);② 真卡库探针(下面这条,手动跑)。 node tools/probe-cardlib.mjs 12 # 真卡库:列表/搜索/抽样解析/真导入(只读 + 写临时目录) # 6) 往返一致性(要在 profile 的 node_modules 目录里跑:那里才解析得到 @deepseek-ai/dsh-tools) Copy-Item tools/verify-roundtrip.mjs "$dst\_roundtrip.mjs" -Force cd $dst ; node _roundtrip.mjs "<你的 checkout>\lib\card-import.js" Remove-Item "$dst\_roundtrip.mjs" ``` **验证改动是否真的生效**(别只信「我重启了」):比对监听进程的 PID 与启动时间。 已经踩过两次——一次重启发生在我同步代码**之前**,一次重启命令**根本没执行**, 两次都表现为「改了代码但行为没变」,白查一轮。 ```powershell Get-NetTCPConnection -LocalPort 3080 -State Listen | ForEach-Object { Get-Process -Id $_.OwningProcess } | Select-Object Id, StartTime ``` **发布**:`git add -A && git commit -m "..." && git push`(仓库已配好 origin;GitHub 账号 SiriusWJ,token 含 `repo`+`workflow`)。 **npm 不发布**(用户明确要求)。 --- ## 5. 未解决 / 待验证(按优先级) 1. **右栏面板的界面确认**(入口按钮已实测出现): - 验证:**刷新页面** → 打开 DM 会话 → 右上角应出现「🎲 RP」→ 点它 → 右栏应展开并显示 RP 面板 → 右栏的收起 / 浮动按钮应正常作用。 - 客户端 bundle 是**页面加载时**读取的,所以改完 `client/client.js` 只需刷新页面,**不必重启宿主**; 只有改了 `lib/index.js` / `rp-bridge.mjs`(宿主半侧与作用域半侧)才需要重启。 - 若入口出现但点了没反应:打开控制台看有没有 `[rp-tools] 打开 RP 右栏页签失败` 或 `注册右栏页签类型失败` —— 前者说明没有挂载的右栏座位(右栏被折叠到不渲染),后者说明 `sidebarRightTabs.register` 抛错(id/kind 撞车)。也可以直接看右栏条的「+」菜单里有没有 「🎲 RP 跑团面板」这一项(注册 `guide` 之后会出现)。 2. **故事书导入面板的界面确认**(宿主 4 条路由已用 HTTP 实测: `/rp-tools/cards` 返回 3269 张、`/rp-tools/card` 解析成功、两条逃逸请求都是 400): - 验证:**刷新页面** → 新建会话(Hero 上应出现「📖 导入 PNG 故事书」)→ 点开应列出卡库分类与卡片 → 选一张应出预览(世界书条数 / 开场白来源 / 世界与性格摘要)→ 点「导入并开始」应: ① 会话预设变成 `dm`(右上角预设标签);② 输入框被自动填上开场指令并发出;③ 工作区出现 `rp-sessions/<会话 id>/` 下的世界书与 `cards/`;④ RP 面板的角色卡下面出现卡面立绘。 - 已知**未在浏览器里跑过**的部分:`remote.agentPresets.select()` 与 `uiWorkspace.startSession()` 都只能在页面里验证(宿主侧没有等价入口)。若切预设失败,面板会**显示原因**而不是静默 —— 先看那行字。 3. ~~**`rp_scenes` 未用真实 `scenes_*.json` 实跑过**~~ **(作废:`rp_scenes` 已随 1.15.0 移除生图一并删除,相关样例文件也不再与插件有关)**。 4. ~~**角色一致性只做了第一版**~~ **(作废:1.15.0 移除生图,`characterSeed()` / `characterInPrompt()` 都没了)**。 现在的一致性手段只剩 persona 里那条纪律:**把角色卡的 `appearance` 写进 `generate_image` 的画面描述** (顺序:发型颜色 → 眼睛 → 肤色体型 → 身高 → 穿着 → 配饰)。历史记录:当时的想法是接参考图 (本机有 `qwen_image_2512_fp8_e4m3fn` + Qwen VL 编码器 + `Qwen-Image-Edit-2509-Lightning-4steps` LoRA + `ReferenceLatent` 节点;**没有** `IPAdapterModelLoader`),代价是新增第二个工作流模板 + 首次加载 ~20GB 模型。 5. ~~**LoRA 强度未暴露**~~ **(作废:LoRA 与工作流模板已随 1.15.0 删除)**。 6. ~~大尺寸(1664×928 等)未压测;媒体代理会把整图读进内存~~ **(作废:尺寸体系与 `/rp-tools/media` 代理已随 1.15.0 删除)**。 7. 面板「掷表」走独立路由 `/rp-tools/roll`,与 `rp_table` 工具共享 `parseDice`/`rollDice`。 8. **预设 id 目前硬编码为 `dm`**:若把预设目录改名,判定会失效(客户端、`markDmSession`、 导入的 `select()` 都写死 `'dm'`)。要支持改名就把预设 id 提成一个常量或配置项。 9. **导入的叙事设定只做了「卡 → 世界书」这一层**:卡里的 `personality` 目前整段塞进角色卡的 `personality` 字段(`appearance` / `speech` / `behavior` / `relations` 留空,等 DM 提炼)。 下一步可让 DM 首轮把它拆成 8 个字段(`rp_character` 已经支持),但别让插件去猜。 10. **并行会话冲突**(真的发生过):另一个 agent 会话曾同时改这个仓库,撞在 `lib/index.js`、 `tools/smoke-dm.mjs`、`docs/REFERENCE-COMPARISON.md` 上,还留下过 `lib/card-index.js` (482KB 私人卡库索引 —— 已加进 `.gitignore`,因为 `package.json` 的 `files` 含 `lib/`, 不加会被提交并随 npm 包发布)。**同时开两个会话改这个仓库前,先约定分工。** 11. **ST 式「正则表」:明确不做**(用户 2026-09-12 决定,别再自作主张加)。理由与现状: - 两个参考仓库里 **`{{user}}` 不是正则,是「宏」**:dsh-liketavern 有独立的宏展开器 (`lib/core/macros.d.ts`,支持清单明确),dsh-roleplay 的 `rp-macro` 更保守(只有 `{{char}}`/`{{user}}`)。 正则表是**另一件事**:作用域(用户输入 / AI 输出 / 发送给模型)+ 时机(组装前 / 发送前 / 渲染前)+ find/replace + 深度 + 来源(用户 / 角色卡 / 预设 `extensions.regex_scripts`)。 - 代价:liketavern 的 AGENTS.md 明确要求**第三方正则不许在宿主主线程跑**(必须进 QuickJS 隔离 worker,防 ReDoS); 要作用到「AI 输出」还得在 DSH 里挂 `conversation.chat.node`。与「轻量跑团工具」的定位不符。 - 我们现在走的是 DSH 原生路线:`{{user}}` 注册成**宿主变量**(`systemPrompt.variable`), 导入时再按卡名展开 `{{char}}` 等(`resolvePlaceholders`),其余未注册的宏中和成全角。 需要「清标签 / 统一标点」时,用 dm 预设的 persona 指令或直接改世界书条目即可。 --- ## 6. 踩过的坑(别再踩) > **历史标注(1.15.0)**:下表里凡涉及 **ComfyUI / 本地生图 / 风格库 / 负面词 / 图像尺寸体系** 的条目, > 都是**本地生图被整条移除之前**踩的坑 —— 那些功能已经不在本插件里了(对应的工具与路由都没了), > 但「为什么会那样」的教训仍然成立,所以原样保留,只在现象列加了 **(历史)** 标记。 > 新会话读到这里时:**别照着这些条目去找已经删掉的代码**。 | 现象 | 根因 / 解法 | |---|---| | 客户端卡片/按钮完全不渲染 | bundle 的 `factory` **必须自己声明** `var module = { exports: {} }`(官方 bundle 同样),否则 `module.exports.*` 赋给了错误对象 | | 按钮「静默不出现」且无报错 | 用 `String(fromHook \|\| props.sessionId \|\| '')` 取会话 id 时,钩子的**空串**会把后面兜底短路 → 查询落到未知 id → `isDm:false`。空串要当「真值缺失」跳过:`[a, b, c].find(v => typeof v === 'string' && v !== '')` | | 同源路由测试里 POST 被 403 | `sameOrigin()` 比对 `new URL(origin).host === request.headers.host`——**假请求必须同时给 `origin` 和 `host` 两个头**(浏览器会强制覆盖 Host,所以真实 CSRF 场景里两者必然不一致) | | 面板又丑又不能收缩 | 别用 `shell.overlay` 自己画浮层(ComfyUI 面板当年就是这么做的,属于反面教材;这条与本插件现状无关)。要「右侧常驻 + 可收起」就注册 `sidebar.right.pane.tab`,外壳交给 DSH | | 下拉框白底看不清 | 原生 `