# 开发状态(STATUS) > 快照时间:2026-09-17 · 版本 1.15.3 · 状态:**本地生图(ComfyUI)已整体移除** —— 配图交给宿主自带的 `generate_image` / `edit_image`,本插件只负责收录与复用(`rp_assets(action:"import")`);设置页收口成三节(设定 / 第三方工具管理 / 诊断),**放行名单按插件聚合、一个插件一个勾选框**(映射表逐插件登记,**没有「其它」大杂烩组**:本机的生图 / genui / 宿主联网 / dsh-mcp / dsh-lite-memory / dsh-updater-npm / dsh-find-plugin 各占一行,漏网的才进兜底组「宿主内置 · 其它」并在组上点名;**清单取「全局层 ∪ 会话作用域」的并集**,注册在会话作用域的内置工具如 `web_search` 不会再被误标成「未注册」);开局任务全在 `cards/.launch.md`;角色行 = 36px 头像 + 名称;技能/装备等动态值住 `rp_state`;资源库按场景/角色/道具分文件夹、按字节去重;会话可导出成单个 zip 并留最近 5 个恢复点;右侧栏 RP 面板入口只在 dm 会话出现** > 这份文档记录「现在做到哪了、什么验证过、什么没验证、坑在哪」——给未来的自己和协作者看。 > > ⚠️ **这个插件在 profile 里是从 GitHub 装的**(`github:SiriusWJ/dsh-rp-tools`): > 改完源码要 **commit → push → `dsh plugin --profile web add github:SiriusWJ/dsh-rp-tools` → 重启**, > 只改本地目录**不会生效**;**dm 预设的活动目录**(`~/.dsh/.agent-presets/dm/`)不属于这个包,仍要手动拷。 > 命令见 README「开发」一节。 > > 本机到 `codeload.github.com`(GitHub 打包下载域名)吞吐只有 ~25KB/s,仓库 tarball 必须保持小 —— > `temp_output/` 里的试出图(4.3MB)已移出仓库,否则 `github:` 安装会卡满 pnpm 超时。 --- ## 1. 完成度总览 | 模块 | 状态 | 说明 | |---|---|---| | `rp_random` | ✅ 已上线(**dm 作用域**) | 骰子 / 区间 / 加权抽取 / 布尔,`seed` 可复现。曾经是全局工具,现与其余工具一起收进 dm | | 生图链路(直连 ComfyUI) | ❌ **已整体移除**(1.15.0) | 三套工作流模板 / ComfyUI 客户端 / 提示词拼装 / 尺寸体系 / 风格库 / LoRA 清单 / 全局负面词,以及 `rp_styles`、`rp_illustrate`、`rp_scenes` 三个工具与 4 条路由全部删掉。要配图时由**宿主自带**的 `generate_image` / `edit_image` 出图(DM 预设里放行它们,见「第三方工具管理」),本插件只负责把结果收进资源库复用 | | ~~风格库(10 种内置 + 可自定义)~~ | ❌ 已移除 | 随生图链路一起删(`lib/index.js` 里 `defaultStyles()` 缩成只剩 `cards` 的 `defaultConfig()`);风格词搬进 persona 当**可选风格词**。配置文件常量改名 `RP_CONFIG_FILE`(**文件名仍是 `styles.json`**,不动老用户的盘;盘上老的风格字段只忽略、不删) | | ~~`rp_illustrate` / `rp_styles` / `rp_scenes`~~ | ❌ 已移除 | 工具面 11 → **8**(`rp_random` / `rp_session` / `rp_character` / `rp_config` / `rp_table` / `rp_lore` / `rp_state` / `rp_assets`) | | **资源库 `rp_assets`(收录与复用)** | ✅ 已实现(未长跑验证) | 新图入馆的唯一途径是 `action:"import"`:吃 `path`(宿主 `generate_image` 返回的 `savedTo`;绝对/工作区相对/只给文件名都认)或 `attachment_id`(图没落盘时走宿主附件服务);只读、限工作区内 png/jpeg/webp、≤8MB,越界拒绝;去重**优先按内容**(同一张图先当 scene 再当 other 不会存两份)。另有 `list` / `get` / `tag`(按分类、角色、标签、关键词查)| | **PNG 故事书导入** | ✅ 宿主已验证(界面待确认) | 工作区那一行的「📖 导入 PNG 故事书」:列卡库(3269 张,服务端过滤/分页)→ 预览 → 导入。世界书**追加合并**进 `<工作区>/rp-worldbook.md`、卡全文 `rp-sessions/<会话 id>/cards/.md`(超预算条目的去处)、卡面复制当立绘;角色/世界/战役名写进会话配置;自动切 `dm` 预设 + 发开场指令。真卡库实测:160/160 解析成功(中位 1ms)、642 条目的卡导入 25 条 | | `rp_character`(8 字段) | ✅ 已验证 | 增删查 + 立绘;字段含 `first_mes` / `mes_example` 两个**样本字段**(给样本 > 给形容词)。导入的卡面会作为默认立绘显示在角色卡下面 | | **提示词注入(两条通道)** | ✅ 代码已修,**待重启实测** | `rp:standing`(order 210) 放战役名/世界设定/**角色索引**/**常驻世界书条目**;`rp:turn`(order 20,runtime context) 放**当前状态 + 命中条目 + 在场角色详细卡**。⚠️ 1.10.x 及以前这两条**在生产里一次都没生效**(会话 id 取成了 `default` + dm 预设关掉了 runtime context),修法见 §7 的 1.11.0;用 `tools/verify-injection.mjs` 读会话日志验证 | | **世界书** | ✅ 已实现(未长跑验证) | 会话工作区的 `rp-worldbook.md`;`##` 分条、`keys/constant/order/prob` 标记;常驻进系统提示、命中进每轮快照 + 预算 + 被裁列出标题;**空壳条目(正文只有模板残留)一律不注入**并在面板折叠提示;`rp_lore` 按条补读 | | **状态追踪(`rp_state`)** | ✅ 已实现(未长跑验证) | 场景/时间/地点/在场/线索 + 队伍(状态·持有·伤病·目标)+ 自由旗标;空串即清除;注入在 turn 通道最前;多轮未更新会提醒 | | `rp_session`(会话隔离) | ✅ 已验证 | A/B 两会话实测互不干扰 | | `rp_scenes`(整幕批量) | ⚠️ 已实现,未实跑 | 逻辑与 `rp_illustrate` 同源,未用真实 `scenes_*.json` 跑过整幕 | | `rp_config`(全局配置) | ✅ 已实现 | 卡库配置(卡库目录 + 默认宏列表,与设置页共用 `applyCardConfigPatch`)+ **DM 放行的第三方工具名单**(`set_global_tools`);说明里**指路宿主的生图工具**(本插件不做生图) | | `rp_table`(随机表) | ✅ 已验证 | 定义 + 掷表(`1d6 → 4 → 幽灵` 实测) | | 设置页「RP工具」 | ✅ 已上线 | 三节:**设定**(卡库目录 + 默认宏列表)/ **第三方工具管理**(按插件聚合的放行名单:映射表**逐插件**登记,本机是生图 / genui / 宿主联网 / dsh-mcp / dsh-lite-memory / dsh-updater-npm / dsh-find-plugin 七行;一个插件一个勾选框,**没有「其它」组**,漏网的才进兜底组「宿主内置 · 其它」并在组上点名;清单是**全局层 ∪ 会话作用域**的并集,所以注册在会话作用域的内置工具 `web_search` / `web_fetch` 也算「本机有」、不会被标「未注册」;配置里有而两层都没有的名字混进它该在的那组并标 `未注册 xxx`)/ **诊断**(配置文件路径 + rp_* 工具数量)。**整页只有最上面一颗「保存」与一颗「恢复默认」**(第三方工具管理那一节没有自己的按钮:勾选只改本地草稿,保存/恢复默认都归顶上那两颗,两次串行请求写同两份数据)。rp_* 工具清单面板已按要求隐藏,清单仍可用 `/rp-tools/tools` 查;下拉框用主题 token 上色,浅深色主题都清晰 | | DM 会话「🎲 RP」入口 | ✅ **已界面确认** | 判定用 `useSessions().byId[id].projectionValues.agentPreset === 'dm'`(与官方 agent-preset 标签同源);入口在头部**右上角**(`conversation.session.header.utilities`),外观对齐宿主设计系统(28px 高 / 14px 圆角 / 细边框 / 11px 字,面板打开时有按下态) | | RP 面板(右侧栏页签) | ⚠️ **待界面确认** | 注册 `sidebar.right.pane.tab`(key `dsh-rp-tools`):点入口 → `ctx.sidebarRight.openTab()` → 右栏展开;收起/浮动/关闭由 DSH 右栏负责 | | **作用域隔离(全在 dm)** | ✅ **已实测** | host 组合**不注册任何模型工具**;全部 10 个工具与两条注入通道都由 `rp-bridge.mjs` 在 agent 作用域注册。实测:Cordis 会话的工具列表里无 rp、上下文里无跑团内容;dm 会话三条通道齐全 | | dm 预设接线 | ✅ 已配置 | 全部 8 个 rp_* 工具都在本作用域注册(`rp-bridge.mjs`),不需要放行;**预设 YAML 里连 `keepGlobalTools` 键都删了** —— DM 能看见哪些第三方/全局工具完全由设置页「第三方工具管理」决定(没有「固定放行」那一层,手写在 YAML 里的名字仍会并进名单,兼容手改过的人) | | 宿主侧单元测试 | ✅ 已建立 | `tools/smoke-dm.mjs` **1079 条** + `tools/smoke-client.mjs` **480 条** + `tools/smoke-card.mjs` **256 条** + `tools/verify-roundtrip.mjs` **10 条**,另有 `tools/check-no-machine-paths.mjs`(防机台固定路径回归)。覆盖作用域隔离、路由体检、世界书、状态、放行名单与**按插件分组(含「本机全部实际工具都有归属」这道闸门)**、cross-origin 拒绝、fork 继承、PNG 解码、卡库路径逃逸、导入闭环、设置页那张表(含半勾 / 整组开关 / 图片标识 / 未注册点名) | | 真卡库探针 | ✅ 已建立 | `tools/probe-cardlib.mjs`:拿本机 3269 张真卡跑列表/搜索/抽样解析/真导入(只读 + 临时目录) | --- ## 2. 关键设计决策(附理由) **① ~~不依赖 dsh-comfyui 插件~~(生图链路已整体移除,见 §7 的 1.15.0 / 1.15.2)。** 这一段是历史决策记录:最初把风格存成 dsh-comfyui 工作流库里的工作流、走它的 `/comfyui/workflows/run`,实测后改为**直连 ComfyUI**(`POST /prompt` + `/history`),再后来(1.15.0)**整条本地生图链路删掉** —— 出图交给宿主自带的 `generate_image` / `edit_image`。留下这段是为了说明「为什么本插件自己不做生图」:装什么生图插件是用户的事,本插件只做「入口与复用」(资源库 + 立绘位 + 把宿主出图结果收进库)。 - ~~用户本机是 Comfy Desktop,插件不该强迫他再启用另一个插件~~(已无意义:插件不再直连任何生图后端); - ~~媒体用插件自己的同源代理 `/rp-tools/media`~~(该路由随生图链路一起删掉;现在图片 URL 由宿主的生图工具给出、插件只负责收录); - 这条决策的**残值**:「不把用户的机器配置硬编码进插件」这个原则留下来了 —— 生图后端的选择权在用户(装哪个插件、用哪个模型),插件只登记工具名(见「第三方工具管理」)。 **② ~~风格 = 内置工作流模板~~(随生图链路一起移除)。** `WORKFLOW_TEMPLATES` / `buildWorkflow` / 风格库 / LoRA 清单都已删。风格词现在是 persona 里的一句**可选风格词**(DM 自己把风格写进 `generate_image` 的提示词),不再是插件里的数据结构。 **③ 仍有效(当前版本的实现)。** - **工具层**:host 组合**不注册任何模型工具**;全部 8 个 `rp_*` 都由 `registerRpTools(ctx)` 在 **agent 作用域**注册(由 dm 预设的 `rp-bridge.mjs` 调用) → 非 dm 会话**看不到也用不到**。 - **提示词注入**:同样只在 agent 作用域注册(`installStandingPrompt`)。`system-prompt/assemble` 是**按作用域过滤**的事件,所以回调只收到本会话的装配 —— 「只在 DM 生效」与「会话隔离」 由 Cordis 免费保证,**不需要猜会话 id**。 ⚠️ 早先版本把它放在全局 `apply()` 里并写了一套「猜当前会话」的启发式,两个错都犯过: 全局注册让每个会话都带上跑团世界观;猜会话会**把别的战役设定注进来**。相关代码已全部删除。 - **界面层**:`conversation.view`(页签)**无法按会话条件注册**(全局注册即全局出现),因此用 `conversation.session.header.utilities`(会话头部右上角按钮)。 - **判定来源**:客户端从 `useSessions` 读 `byId[sessionId].projectionValues.agentPreset`—— 官方 `agent-preset` 标签组件渲染「本会话跑的预设」所用的同一字段。等于 `dm` 就是 DM 会话。 回退路径:查 `/rp-tools/session` 的 `isDm`(预设投影未就绪时用)。 **④ 会话级 vs 全局的划分。** - 全局(`styles.json`,常量名已改成 `RP_CONFIG_FILE`,**文件名不动**):**卡库配置**(卡库目录 + 默认宏列表)+ **DM 放行的第三方工具名单**(`globalToolsAllow`)。(风格库 / ComfyUI 地址 / 负面词 / 全局默认风格都已随生图链路删掉。) - 会话(`sessions/.json`):世界设定、角色卡、随机表、提示词前缀、战役名。(会话默认风格、风格备注已删;盘上老字段只忽略、不删。) - 会话 id 来源:工具侧取 `exec.agent.id`;界面侧取 `useSessions((s) => s.current)`。 **⑤ ~~负面词为什么"可能不生效"~~(负面词已随生图链路移除)。** 历史记录:Krea-2 Turbo 官方推荐 CFG=1,而 CFG=1 时 KSampler 的负向条件被乘以 `(1-cfg)=0` —— 数学上不参与计算,所以插件当年的负面词在默认风格下**本来就不生效**(界面会提示要调到 CFG 1.5~2.5)。现在负面词这个字段与相关 UI 都不在了;要控制「不要画成什么样」,直接写进给宿主 `generate_image` 的提示词里。 --- ## 3. 验证记录(实测) **生图端到端**(~~`rp_illustrate`,默认 `manga` 风格~~ —— **这条记录对应的是已删除的功能,1.15.0 起不再适用**;保留是为了对照当年的实测数据): ``` 风格 黑白漫画(manga)· 1368×768 · 16.6s 提示词 = 触发词 + 用户描述 + 命中角色的外观 media: /rp-tools/media?file=rp_00002_.png&subfolder=&type=output 媒体代理:HTTP 200 · image/png · 1.6MB ``` **会话隔离**(`rp_session` / `rp_character`): ``` [A 会话] 战役名:灰烬纪元 | 前缀:cinematic lighting | 角色:凯尔 [B 会话] 战役名:(未设) | 前缀:(未设) | 角色:0 个 ← 完全隔离 ``` **作用域隔离(实测)**: ``` 全局注册的工具: (零个) dm 作用域注册的工具: rp_random, rp_character, rp_state, rp_lore, rp_session, rp_config, rp_table, rp_assets(8 个) /rp-tools/tools → 8 个工具(插件自己的清单,与作用域无关) Cordis 会话(非 dm):工具列表里无 rp_*,运行时上下文里无跑团内容 dm 会话 :三条通道齐全 —— 世界观(standing) / 当前状态 + 世界书命中 + 在场角色(turn) ``` **随机表**: ``` 🎲 地下城遭遇表(1d6,6 条) 1d6 → 4 → 幽灵 1d6 → 4 → 幽灵 1d6 → 2 → 陷阱 ``` **~~ComfyUI 连通性~~(`/rp-tools/check` 路由已随生图链路删掉)**:当年的记录是 `0.35.0 | cuda:0 NVIDIA GeForce RTX 5080 | vram_free ≈ 2.9GB`。 **宿主侧单元测试**(`tools/smoke-dm.mjs`,伪造 ctx + Readable 请求体;`DSH_HOME` 指向临时目录,不碰真实数据): ``` 被测模块: /node_modules/dsh-rp-tools/lib/index.js(由 DSH_RP_PROFILE_PACKAGE 解析,不写死机台路径) 注册的工具: rp_random, rp_character, rp_session, rp_config, rp_table, rp_lore, rp_state, rp_assets PASS POST dm-mark 同源登记 / GET session(已登记 dm).isDm=true / (session- 前缀)=true PASS GET session(未登记).isDm=false ← 入口不会污染其它会话 PASS POST dm-mark 跨域被拒 got=403 PASS 登记后 isDm=true / 重复登记幂等 / 回读 preset="dm" PASS 工具调用保底登记 =true PASS agent/created 自动识别 dm 预设 =true ← 宿主侧可直接识别 PASS agent/created 不误登记非 dm =false …(其余为世界书 / 状态 / 资源库 / 放行名单与按插件分组 / 会话包等) 结果: 1079 通过 / 0 失败 ← 另有 smoke-client 480 / smoke-card 256 / verify-roundtrip 10 ``` **槽位契约核对**(`cordis_inspect_query` → client `Slots.listSubTree`): `conversation.session.header.actions` 的 `standardProps` **同时包含 `useSessions` 与 `sessionId`**, 本插件 cell(id `rp-tools`,order 40)在客户端 `active: true`; `useSessions` 的 `list` store(`@deepseek-ai/dsh-api-session-controller`)里 `current` 存的是**裸 id 字符串**。 --- ## 4. 已知问题与限制 | # | 问题 | 影响 | 现状 / 计划 | |---|---|---|---| | 1 | **DM 会话判定** | 曾导致「🎲 RP」按钮不出现 | **已修(待界面肉眼确认)**:根因有两层——① 会话 id 前后缀不一致(工具侧 `session-` vs 界面侧裸 ``),已由 `normalizeSessionId()` 统一;② **更隐蔽的一层**:客户端写 `String(fromHook \|\| props.sessionId \|\| '')`,而钩子初值是 `undefined`、切换期可能是**空串**,空串会把兜底短路 → 永久 `isDm:false` 且无任何报错。现改为「第一个非空字符串」取 id,并改用 `projectionValues.agentPreset === 'dm'` 作为权威判定(官方标签组件同源字段)。宿主侧 11/11 断言通过;只剩刷新页面确认 | | 2 | ~~负面词在 CFG=1 下无效~~ | ~~全局负面词形同虚设~~ | **不适用了**:负面词字段随生图链路(1.15.0)一起删掉;要控制画面就直接写进给宿主 `generate_image` 的提示词 | | 3 | 面板上的「掷」走独立路由 | 与工具逻辑重复 | 已抽成同源实现(路由与工具共用 `parseDice`/`rollDice`);如需完全统一可让路由调用工具内部函数 | | 4 | ~~角色一致性~~ | ~~同一角色换姿势/场景会漂~~ | **不再是本插件的问题**:本插件不出图,一致性取决于宿主的 `generate_image` / `edit_image`(可用 `edit_image` 基于已有立绘改)。插件侧只剩 `rp_character.character_sheet` 外观锚点当**提示词素材** | | 5 | ~~LoRA 强度未暴露~~ | ~~只能 1.0~~ | **不适用了**:LoRA / 风格库 / 工作流模板都已删除 | | 6 | ~~大尺寸未压测~~ | ~~>1.5MP 的画质/耗时未知~~ | **不适用了**:尺寸由宿主的生图工具决定 | | 7 | ~~媒体代理把整图读进内存~~ | ~~大图/视频会吃内存~~ | **不适用了**:`/rp-tools/media` 已删;现在只读工作区内的图片文件(`rp_assets` 导入时限 8MB、只认 png/jpeg/webp) | | 8 | 客户端 bundle 需手动同步 | 改了不生效 | `file:` 依赖是安装期拷贝;已在 README「开发」写明同步命令。注意加载时机不同:**客户端 bundle 只在页面加载时读取(改完刷新页面即可)**,宿主 `lib/` 只在重启时装载 | | 9 | 预设 id 硬编码为 `dm` | 预设目录改名则判定失效 | 客户端、`markDmSession`、导入时的 `agentPresets.select()` 都写死 `'dm'`;要支持改名就提成常量/配置项 | | 11 | 故事书导入的**界面**未肉眼确认 | 点「导入并开始」后是否真的切预设 + 发开场 | 宿主 4 条路由已 HTTP 实测(列表 3269 张、解析成功、逃逸 400);`remote.agentPresets.select()` 与 `uiWorkspace.startSession()` 只在页面里能验。**失败时面板会显示原因**,不会静默 | | 10 | ~~fork 分叉后会话配置丢失~~ | ~~新建分支后世界 / 角色卡 / 随机表全没了~~ | **已修(待重启确认)**:`session/created` 时按 `header.parentSession` + `header.isSeeded` 判定「这是一次 fork」,把父会话的 RP 配置复制给子会话(快照语义,父子之后各改各的);子会话已有配置则不覆盖;`isSeeded` 对 resume 为 false,所以重启恢复不会被误判。**宿主半侧改动,需重启生效** | | 12 | ~~注入在生产里完全没生效~~ | 世界设定/角色卡/世界书**一次都没进过上下文**(只有占位文案) | **已修(待重启实测)**:① 会话 id 从 `ctx.agent.id`(不存在的属性)改为装配上下文的 `context.agent`;② dm 预设的 `suppressRuntimeContext: true` 改为显式 `false`(`session-filter-v2.mjs`)。用 `node tools/verify-injection.mjs` 读会话日志验收:修好后应看到 `世界设定=YES` 与 `轮次快照>0` | | 13 | 世界书里「状态/历史」类条目 | 当常驻会一直占每轮上下文,且会过期 | 面板已给类别标签与「常驻存疑」提示,**但不自动改语义**(不替用户把 constant 关掉)。待定:是否把这类条目自动降级为触发式,或引导改写成 `rp_state` | | 14 | 「很多卡不像世界书、像历史状态」 | 规则解码出来的条目质量不稳 | 用户问过「要不要导入时让临时 agent 来填」。**建议不走额外 agent**:卡全文已经落在 `rp-sessions//cards/.md` 里,让 DM 在开局那一轮顺手整理更省(上下文已经在了、由真人看着改)。待定:面板给一个「让 DM 整理世界书」的按钮(发一条指令) | --- ## 5. 路线图(按优先级) 1. **重启后跑一次 `node tools/verify-injection.mjs`** 验收 1.11.0 的修复:全新的 DM 会话里 `世界设定=YES`、`轮次快照>0`(旧会话的历史改不了,只有新回合才走新代码)。 2. **肉眼确认两处界面**:① 头部「🎲 RP」→ 右栏 RP 面板;② 工作区那一行的「📖 导入 PNG 故事书」→ 完整走一遍「选卡 → 导入并开始」,确认预设真切成 `dm`、开场指令真发出、工作区真落了文件。 之后读 `_agent-probe.json`:若 `options.scalars.preset === 'dm'`,说明宿主侧 `agent/created` 也能直接识别,可删掉 `rp-bridge.mjs` 里的登记段,把三条路径收敛成一条。 3. **让 DM 首轮把导入的设定拆成 8 个字段**(`personality` 目前整段塞进去,`appearance`/`speech`/ `behavior`/`relations` 留空等 DM 提炼;`rp_character` 已支持)。 4. **角色一致性**:基于 Qwen-Image-Edit(本机 `qwen_image_2512_fp8_e4m3fn` + `Qwen-Image-Edit-2509-Lightning-4steps` LoRA)做「参考图 → 同角色新姿势」,作为 `rp_illustrate` 的可选 `reference` 参数。导入的卡面已经在工作区里,可直接当参考图。 5. **LoRA 强度**:风格条目加 `loraStrength`,设置页给滑块/输入框。 6. **整幕流水线**:用真实 `scenes_*.json` 跑通 `rp_scenes`,并把「幕 → 多格 → 拼页」流程写进文档。 7. **随机表联动**:`rp_table` 掷出的结果可以一键转为配图提示词("掷到幽灵 → 顺手出一张幽灵立绘")。 8. **导出**:把整个会话的 RP 配置(角色卡/世界/随机表/出图历史)导出成一份复盘文档。 9. **预设 id 参数化**:把硬编码的 `'dm'` 提成常量/配置,支持预设目录改名。 10. **ST 式正则表:不做**(2026-09-12 决定)。`{{user}}` 这类是**宏**(已用宿主变量 `systemPrompt.variable` 实现), 正则表是另一件事,且要求把用户正则放进隔离执行器(参考实现如此),与轻量定位不符。详见 HANDOFF §5.11。 11. **卡库可搜索化增强**:现在按卡名/作者/标签搜;可以再加「按世界书条目数 / 分类多选 / 只看 v3 卡」。`/rp-tools/cards` 的参数已经留好了扩展位。 12. **世界书整理入口**(见 §4 的 13/14):面板加一个「让 DM 整理世界书」按钮 —— 把「状态/历史」类条目 降级为触发式、把能改成状态表的挪进 `rp_state`。**不加临时 agent**:卡全文已在会话目录里。 --- ## 6. 环境与文件地图 ``` 源码 D:\Code\dsh\rp-tools-plugin 安装位置 ~/.dsh/profiles/web/node_modules/dsh-rp-tools (file: 依赖,安装期拷贝) 预设接线 ~/.dsh/.agent-presets/dm/agent.cordis.yml (白名单 + rp-bridge 条目) ~/.dsh/.agent-presets/dm/session-filter-v2.mjs (仓库内有副本 preset/) ~/.dsh/.agent-presets/dm/rp-bridge.mjs (仓库内有副本 preset/rp-bridge.mjs) 数据 ~/.dsh/data/dsh-rp-tools/{styles.json, sessions/*.json, dm-sessions.json, _agent-probe.json, _standing-probe.json} 私有卡索引 lib/card-index.js (gitignore;缺失时自动扫目录兜底) 卡库 D:\Story\rp-cards\<分类>\*.png (设置页「卡库目录」可改) 导入产物 <会话工作区>\rp-sessions\<会话 id>\{rp-worldbook.md, cards\.{md,json,png}} 诊断 tools/verify-injection.mjs (读真机会话日志,查注入有没有真的生效) tools/check-no-machine-paths.mjs (防机台固定路径回归) 生图 宿主自带的 generate_image / edit_image(本插件不做生图;样式在 persona 里当可选风格词) ``` > 上面这份环境快照里的 ComfyUI / 模型路径两行已随 1.15.0 的移除作废,保留历史版本见 git。 --- ## 7. 变更历史 | 版本 | 主要变化 | |---|---| | **1.15.3** | **去掉「其它」那一组:映射表按插件一条一条登记;清单取「全局层 ∪ 会话作用域」(用户:*rp工具内部 …「其它」里面的内容我看是很多插件的,把其它去掉,按插件分类* + *web_search 是内置的,应该显示它有*)。**A. 归组**:1.15.2 的 `GLOBAL_TOOL_SOURCES` 只有三条(生图 / genui / 联网搜索),本机实际注册的 22 个全局工具里有 **17 个**落在「其它」—— 那行看着就像大杂烩。逐个查清归属(grep 每个已装插件的 `lib/`,看谁注册了这个工具名;宿主**不提供**归属:`schemas()` / `knownNames` 只给名字,`ctx.inspect` 的 Tool.listTools 也只有 name/description/parameters)后补成 7 条:`dsh-image-gen`(生图四件,含名字里看不出图片的 `canvas_state` / `view_canvas`,`image: true` 点名)、`dsh-genui`、`宿主内置 · 联网搜索`(键从 `dsh-web-search` 改成 `dsh-host-web`)、**`dsh-mcp`**(`mcp_tool_search` / `mcp_servers`)、**`dsh-lite-memory`**(记忆 + 日历 + 定时**合成一条**,13 个工具一个勾选框 —— 它们本来就是同一个插件)、**`dsh-updater-npm`**(`dsh_docs_read` / `dsh_docs_search`)、**`dsh-find-plugin`**(`find_dsh_plugin`)。映射表真漏了才落 `__unmapped__` 兜底组,组名是「**宿主内置 · 其它**」(第一版写成「未登记归属(映射表缺条目)」,用户看了说 *未登记的改成系统内置* —— 那是给维护者看的待办,不是给用户的分类),并在行上列出工具名;也故意**不静默隐藏**(用户要的是「按插件分」,不是把不会分的藏起来)。**C. 并集必须过滤**:第一版把样本 agent 的作用域工具**照单全收**,而真机上样本就是「用户当前那个对话」的 agent —— 它的作用域里有 `read`/`write`/`glob`/`pwsh`/`todo_write` 等 26 个**自己的**工具,于是设置页凭空多出 26 行「兜底组」,`available` 从 23 涨到 51(**这条是真机接口上抓到的**,测试夹具照着补了 `glob`/`pwsh`/`todo_write` 三个假名字钉住它)。现在只收**放行名单里点过名的**会话作用域工具:`web_search` 照旧显示成「本机有」(它在出厂默认名单里),agent 自己的工具一个都不进来。全量改按你选中那项:兜底组标签从「未登记归属(映射表缺条目)」改成「**宿主内置 · 其它**」。**D. 保存入口收成一个**(用户:*去掉截图里的面板,rp 工具统一用最上面的保存*):第三方工具管理那一节的「手填工具名」输入框与「保存 / 全部放行 / 全部取消 / 恢复默认」整排删掉(连带 `.gtdraft` / `.gtAct` 两条样式与 `addGlobalTool` / `saveGlobalTools` 两个函数),勾选只改本地草稿;页面最上面那颗「保存」现在**两次串行请求**(`/rp-tools/config` 写卡库配置 + `/rp-tools/global-tools` 写放行名单),「恢复默认」同样两件事一起做(配置 reset + 名单写成宿主给的 `gt.defaults`,界面不自己抄一份默认值)。**两个同源的测试坑**:桩里**既没有** `/rp-tools/config` 分支**也没有** `/rp-tools/reset` 分支 —— 两者都落到末尾那句 `{ok:false}`,于是「保存 / 恢复默认」在第一发就抛错、**第二发永远发不出去**,新加的断言只看到 `undefined`(查了半天才意识到是桩没铺路,不是实现错;顺带把 `stateStub` 从 `const` 改成 `let` 让配置桩能回写)。**B. 内置工具被误判**:`tools.schemas()` **省略 scope 只给全局层**,而 `web_search` / `web_fetch` 是宿主注册在**会话作用域**的 —— 于是设置页把一直在用的 `web_search` 标成「未注册」(用户的原话:*web_search 是内置的,应该有这个工具*)。实测确认 `schemas(agent)` 会带上该作用域可见的那一份(`view(scope)` → `chainLayers(scope)`,文档也写着 *scope: the viewing scope (the agent)*),于是记一个**样本 agent**(只存引用、不碰字段、拿不到就退回纯全局视图),`listGlobalToolNames()` 取**并集**,并**滤掉 `rp_*`**(那是本插件自己注册在 dm 作用域的,不是「要放行给 DM 的第三方工具」)。样本有两个来源,缺一不可:`apply()` 里的 `agent/created`(新建会话时)**和每轮的 `system-prompt/assemble`(`context.agent`)** —— 后者是真机上补空档的那条:插件是**运行中重新装载**的(改完代码就是这个场景),已经在跑的会话不会再发一次 `agent/created`,只靠前者样本会一直是空、`web_search` 继续被标「未注册」(本机实测就是如此)。`session/disposed` 时把样本放掉(比 id 前先 `normalizeSessionId` —— agent.id 是 `session-`、disposed 给的是裸 uuid,两种形式混用是本仓库的老坑)。**顺带**:「本机没注册」不再自成一组(`__missing__` 这个键消失)—— 配置里有的名字混进它该在的那一组、工具上带 `missing`,界面在组上点名 `未注册 xxx`,不再把同一个插件的工具劈成两处。**为了不再走散**:`groupGlobalTools()` 从 `lib/index.js` 挪进共享模块 `lib/global-tools-defaults.js`,客户端冒烟测试的桩**直接调它**(原先 stub 自己抄了一遍分组循环 —— 这种「桩抄实现」在本仓库栽过,测试会绿着而真机不对)。**测试**:smoke-dm **1079**(夹具改成两层:全局层 17 个真名 + 会话作用域 `web_search` / `web_fetch` + 假的 `my_image_plugin`;新增:没有样本时看不到会话作用域工具且 `web_search` 进 missing、塞进样本后两者都出现且不再 missing、`rp_*` 不进清单、七个组各自点名、没有 `__other__` / `__missing__`、**「本机全部实际工具都有归属」这道可执行闸门** —— 以后装了新插件没补映射表就红)、smoke-client **480**(五个插件行、记忆行合成一组、没有「其它」、未登记那组要点名、`web_search` 混进宿主联网组而不是单独劈一组)、smoke-card 256、verify-roundtrip 10、check-no-machine-paths 通过,全 0 失败。**未验证**:宿主半侧要**重启 dsh 才装载**(跑在进程里的还是旧那份),这份改动只落到仓库与 profile 的那份拷贝上。 | | **1.15.2** | **「第三方工具管理」改成按插件聚合:一行一个插件、一个勾选框管该插件的全部工具**(用户:*工具列表按插件名汇聚,只用勾选插件就好了*)。1.15.1 的两点随之作废并改掉:**③「一行一个工具」→ 一行一个来源插件**(勾它 = 该插件全部工具一起放行/取消,行下方一行小字列出它带的工具名,勾选因此自解释)、**④图片标识的判定口径改了** —— 1.15.1 是「工具名像图片工具」,而 `dsh-image-gen` 带的 `view_canvas` / `canvas_state` 名字里根本没有 image 之类的词,逐名判断的结果是**真正的生图插件反而不带 🖼 标**;现在改成**映射表点名**(`GLOBAL_TOOL_SOURCES` 的条目加 `image: true`),并保留「整组工具名都像图片工具」当兜底(覆盖没进映射表的新插件)。**宿主查不到工具归属**,所以分组只能靠一份映射表:实测 `tools.register(definition)` 只把定义进「当前层」,而 `layers.effect` 的标签是 `tools.register()`(不是插件名),`schemas()` / `knownNames` 也只见工具名 —— 于是 `lib/global-tools-defaults.js` 里维护 `GLOBAL_TOOL_SOURCES`(`dsh-image-gen` / `dsh-genui` / 联网搜索各一条,正则 + 显示名 + 可选 `image`),未命中的工具落进**「其它」组**,**不按名字前缀乱猜**(猜错会让用户以为自己看的是某个插件);新装插件只需加一条数据,界面与前端都不写死插件名。组上仍带 `all` / `some` / `summary`(现成的「全勾 / 部分勾 / N/M + 工具名」判断,界面不用自己数),排序 = **已勾选的组排前面**,组间其余保持宿主声明顺序(稳定)。顺手修掉两个同源的坑:**①「未注册」那组原先借用「其它」的键 `__other__`** —— 真机上「有未映射工具 + 配了没装的名字」同时出现时(这台机器就是这样)两组键相撞,界面按 key 渲染会互相顶掉,现在未注册组用 `__missing__`;**②半勾状态此前只有 DOM 属性**(React 的 `indeterminate` 得直接写节点,函数式 `ref` 里做),测试桩只认对象式 `ref` 所以从没被断言过 —— 现在测试直接调那个函数式 ref 取值,把「部分勾选显示成半勾」钉住。**测试**:smoke-dm **1053**(+22:分组三条、组上图片点名的两条、「其它」兜底与混合组不打标、未注册组自成一组且键不撞、整组开关、只勾一个插件后其余组变未勾)、smoke-client **458**(+10:三行 = 三个插件、每行只有一个勾选框、行下列出工具名、**图片标识必须看 badge 元素** —— 原先断言整行文字里有没有「图」字,而组名里本来就写着「(生图)」,那是假绿、默认态下 `2/3` 的组**半勾而不是勾上**(默认名单 5 个工具、本机注册 6 个,真机就是这个样子)、点勾选框 = 整组开/关、取消的组沉到后面、未注册组、全不勾的后果提示)、smoke-card 256、verify-roundtrip 10,全 0 失败。 | | **1.15.1** | **设置页收口:放行名单改成「第三方工具管理」一张表**(用户提的五点)。① 原名「DM 会话放行」→**「第三方工具管理」**,语义从「额外放行哪些」变成「DM 能看见哪些**非本插件**的全局工具 —— 这是一份**完整名单**」;② **取消「固定放行」**:`render_ui` / `validate_dsh_ui` / `web_search` 以前是用户不可取消的底线,现在与其它工具一样**可勾选、默认勾选**(开箱行为不变,但想关就能关;关掉 `render_ui` 或全不勾时界面给明确后果提示,**不阻止** —— 那是用户自己的机器);③ **一行一个工具**(原来是挤在 `flex-wrap` 里的胶囊);④ **图片类工具有标识**(🖼 图标 + 「图」徽标;判定正则收窄成 image/img/photo/picture/draw/paint/illustrat —— 刻意不再匹配 `vision`/`caption`,它们在别的插件里未必指图片,打错标比不打更误导);⑤ **已勾选的排前面**(同组内按名字排,位置稳定)。实现上把 `GLOBAL_TOOLS_BASE` + `GLOBAL_TOOLS_ALLOW_DEFAULT` **合并成一份** `GLOBAL_TOOLS_DEFAULT`(「底线」这个区分不存在了),`normalizeGlobalToolsAllow` 不再剔除「底线名」(它们现在也是普通可选项),过滤器的兜底默认改成这份完整名单(配置读不到/读坏时 DM 不会变成什么工具都看不见;**显式空数组**才是「一个都不放行」)。预设 YAML 里连 `keepGlobalTools` 键一起删掉(放行完全由设置页决定;手写在里面的名字仍会并进名单,兼容手改过的人)。**另:隐藏 rp_\* 工具清单面板**(用户要求)—— 设置页那一节只剩「诊断」(配置文件路径 + 工具数量);清单本身仍可用 `/rp-tools/tools` 查。**测试**:smoke-client 首次覆盖设置页那张表(原先只断言「槽位注册了」,等于这块 UI 零覆盖):新增 21 条 —— 改名、无固定放行(**不该有任何 disabled 勾选框**,这条是防回归闸门)、一行一个、图片标识、**已勾选排前面**(默认 5 勾 1 不勾时前 5 行必须是勾上的、最后一行不勾)、未注册名字显示成「未注册」行、全不勾时的后果提示、rp_* 清单已隐藏但配置文件仍可见。smoke-dm 1033 / smoke-client **448** / smoke-card 256 / verify-roundtrip 10,全 0 失败。 | | **1.15.0** | **完全移除本地生图功能(ComfyUI)**,出图改由宿主自带的 `generate_image` / `edit_image` 承担;插件侧只保留「图片的入口与复用」。**删掉的**:三套内置工作流模板与 `buildWorkflow`、ComfyUI HTTP 客户端(`comfyApi` / `submitWorkflow` / `waitForImages` / `comfyStatus` / `fetchComfyImageBytes`)、提示词拼装(`buildPrompt`)、尺寸体系(`IMAGE_SIZE_SLOTS` / `DEFAULT_IMAGE_SIZES` / `LEGACY_*` / `FALLBACK_IMAGE_SIZES` / `normalizeSizePair` / `resolveImageSizes` / `aspectToSize`)、风格库(`defaultStyles` 的三套风格 / `styleSummary` / `applyStyleOps` / `newStyleTemplate` / `listLoras` / 风格与 LoRA 迁移)、全局负面词、角色固定种子(`characterSeed` / `characterInPrompt`)、`recordGeneratedPortrait` / `illustrateSizeKey` / `mediaUrl`,以及 **3 个工具**(`rp_styles` / `rp_illustrate` / `rp_scenes`)与 **4 条路由**(`/rp-tools/media` 媒体代理、`/rp-tools/preview` 试出、`/rp-tools/loras`、`/rp-tools/check`)。工具面 11 → **8**、路由 29 → **25**。**保留的**:资源库 `rp_assets`、立绘位、玩家导入图片(`asset-upload` / `portrait-image` / `asset-image`)、卡面、常驻段的「已有可用图」—— 老图墙与已有资源条目照旧可查可看。**新增 `rp_assets(action:"import")`**:新图入馆的唯一途径,吃 `path`(宿主 `generate_image` 返回的 `savedTo`,绝对路径 / 工作区相对路径 / 只给文件名都认)或 `attachment_id`(图没落盘时走宿主附件服务 `agent.ctx.get('attachments').readImage`),只读、限工作区内 png/jpeg/webp、上限 8MB,越界与超大一律拒绝;没给参数时**报错会列出 `dsh-image-gen/` 里最近几张的可用路径**(一次调用就能改对)。顺带把去重从「同 kind 同长度」改成**优先按内容**(`findAssetByBytes`)—— 否则同一张图先当 `scene` 收一次、再当 `other` 收一次会存成两份;调用方明确报了分类、而库里那条只是兜底的 `other` 时顺手纠正分类。`rp_config` 改成**卡库配置**工具(卡库目录 + 默认宏列表,与设置页共用 `applyCardConfigPatch`),并在说明里**指路宿主的生图工具**;`rp_session` 去掉 `default_style` / `style_notes` / `images_*` 四个参数与回显;`rp_character` 去掉「顺带出立绘」(连同残留的 `portrait` / `style` 参数与 `media` 输出字段一起清掉;`appearance` 保留,改为供画面描述用);`POST /rp-tools/portrait` 收窄成**只支持显式的 `action:"clear"`**(清理老版本留下的、指向 ComfyUI output 的死引用),导入立绘仍走 `asset-upload`。`loadSession` 不再解析 `defaultStyle` / `styleNotes` / `dm.images`(**盘上的老字段不删**,只忽略,回滚 git 即可恢复);`defaultStyles()` 缩成 `defaultConfig()`,只留 `cards`;配置文件常量改名 `RP_CONFIG_FILE`(**文件名仍是 `styles.json`**,不动老用户的盘)。preset persona 的「## 插画(本地生图可用时)」整节换成「## 配图(用宿主的生图工具)」,原三套风格的触发词搬进去当**可选风格词**(没有 LoRA 了,靠提示词),并**不再复述已删工具的名字**(改为正面说明「本插件不提供出图能力 + 要图就用 `generate_image`」)。**实施中真跑出来的三个缺陷(都属「看起来成功了」那类,详见 `docs/REMOVE-IMAGE-GEN.md` §10)**:① 🔴 `dm-filter` 的 `keepGlobalTools` 白名单漏了 `generate_image`/`edit_image` —— 而这两个是**全局**工具,不放行就被 deny,于是「删了出图能力却没接上新管道」,所有配图说明都是空话;② 🔴 `rp_config` 的 `set` 写成 `cfg = applyCardConfigPatch(cfg, patch)`,而该函数返回的是 **`cards` 那一层** → 盘上少一层壳、`loadStyles()` 下次退回出厂默认,用户的默认宏列表/卡库目录**静默丢失**(已修 + 补读得回来/盘上有 `cards`/顶层未摊平三条断言);③ 🟠 portrait 路由收窄时若把缺省值写成 `'clear'`,老调用(带 `file` 无 `action`,本意是保存)会**静默变成删除** —— 缺省值必须沿用老语义 `'save'`,让它响亮地 400。另一处**坏指导**也一并修掉:旧文案要求模型「拿到图后用 `dsh-ui` 的 image 组件显示出来」,但 `generate_image` 的图**会自动作为附件挂在对话里**(工具结果自带 image 内容块),而 GenUI 的 `image` 只认 `src` 字符串、不认附件 id —— 模型手上根本没有可用的 src,旧写法只会诱导它编一个假 URL。现在口径分开:**刚生成的图不用管显示;只有从资源库重放的图**才用 image 组件(`src` 原样粘 `rp_assets` 返回的地址)。**验证**:`node --check` ×8 文件;smoke-dm **1035** / smoke-client **427** / smoke-card **256** / verify-roundtrip **10**,全 0 失败。方案与实施记录见 `docs/REMOVE-IMAGE-GEN.md`。**补记一(同一版本)**:① `preset/rp-bridge.mjs` 挂载时用 `ctx.tools.schemas()` 做**生图能力自检** —— 读的是应用了作用域过滤之后的可见工具集(`dsh-tools` 里明确写了「a restricted-away global reads as absent」,见 lib/index.js 的 `view()`),所以「读不到 `generate_image`/`edit_image`」就是真被 deny 了,此时打印明确警告。这是把 §10.1 那个**静默**失败变吵的兜底。② smoke-dm 加了**跨文件契约闸门**(实施时正是漏在这里)。③ `readImageForImport` 的越界判定改成**顺着符号链接看真实路径**(`realpathOrNull`):宿主 `saveImageToWorkspace` 是先 `realpath(workspaceRoot)` 再判包含,所以它给的 `savedTo` 是真实路径;而 Windows 上工作区常是 junction,只比字符串会把一份完全合法的图判成越界,用户只看到「导入失败」。两侧都 realpath 之后再比前缀,realpath 失败则退回字符串比对。**补记二(同一版本):全局工具放行名单改成「可勾选的设置」**。起因是用户指出「**每个 DSH 装的生图插件可能不同**」—— 原先把 `generate_image` / `edit_image` 写死在 `preset/agent.cordis.yml` 的 `keepGlobalTools` 里,换个生图插件就得手改预设文件(而预设目录在重装插件时会被覆盖),且**改错了是静默失败**。现在:① 预设 YAML **只留底线**(`render_ui` / `validate_dsh_ui` / `web_search`,去掉任何一个都会当场砸掉卡片渲染、围栏自检或开局考据);② 额外放行改由**插件设置页「DM 会话放行」卡片**勾选,`GET/POST /rp-tools/global-tools` 读写,落盘在 `styles.json` 的 `globalToolsAllow`(出厂默认 `["generate_image","edit_image"]`);③ 设置页用 `ctx.tools.schemas()` **省略 scope = 全局视图**(`view(undefined)` 不套作用域限制)列出**本机真实存在**的全局工具供勾选,生图类自动打「图」标、未注册的名字标「未注册」并支持手填;④ `preset/session-filter-v2.mjs` 挂载时**读那份配置并合并**,读不到/读坏退回出厂默认(**底线永不受影响**;显式空数组才是「只留底线」);⑤ 默认值与归一化规则抽到**共享模块** `lib/global-tools-defaults.js`,插件与过滤器**同源**(各自硬编码必然会走散:底线少一项就是静默坏掉);⑥ `rp_config` 增 `action:"set_global_tools"`(逗号分隔字符串),并在 `get` 里报出放行名单与**本机实际存在的全局工具**(供模型核对名字)。新增 30 条断言(发现 / 勾选落库 / 非法值与底线名剔除 / 未注册项可见 / 工具侧等价入口 / YAML 只留底线 / 共享默认值 / 过滤器确实读配置),smoke-dm 1007 → **1035**。 |① `preset/rp-bridge.mjs` 挂载时用 `ctx.tools.schemas()` 做**生图能力自检** —— 读的是应用了作用域过滤之后的可见工具集(`dsh-tools` 里明确写了「a restricted-away global reads as absent」,见 lib/index.js:2834),所以「读不到 `generate_image`/`edit_image`」就是真被 deny 了,此时打印明确警告并指出该往 `keepGlobalTools` 里加什么。这是把 §10.1 那个**静默**失败变吵的兜底。② smoke-dm 加了两条**跨文件契约闸门**:白名单必须含 `generate_image`/`edit_image`,且「persona 提到生图工具 ⇔ 白名单放行」不能只有一边(实测把白名单那行删掉 → 1005/2 失败,装回去 → 1007/0,闸门真的会响,不是假绿)。③ `readImageForImport` 的越界判定改成**顺着符号链接看真实路径**(`realpathOrNull`):宿主 `saveImageToWorkspace` 是先 `realpath(workspaceRoot)` 再判包含,所以它给的 `savedTo` 是真实路径;而 Windows 上工作区常是 junction,只比字符串会把一份完全合法的图判成越界,用户只看到「导入失败」。两侧都 realpath 之后再比前缀,realpath 失败则退回字符串比对。 | | **1.14.3** | 修导入界面的**卡库变更后死路径**:目录扫描缓存以前永久有效,用户把 PNG 重命名、移动或删除后,「刷新」仍返回旧文件名,点卡/导入就对旧路径 `stat` 并报 ENOENT。现在缓存会记录扫描过的目录 `mtime/ctime`,目录内容变化后自动重扫;「刷新」按钮显式发送 `refresh=1` 强制绕过缓存;预览或导入撞到刚失效的路径时返回 `CARD_LIBRARY_CHANGED`,客户端清掉旧选择并自动刷新列表,不再暴露磁盘 ENOENT。新增宿主回归(重命名自动失效、旧路径错误码、强制刷新)与客户端 `refresh=1` 断言。 | | **1.14.2** | 修「右侧面板的 RP 入口对所有会话可见」:用户要求**只有 dm 预设**能看到。定位到**根因是注册位置**,不是渲染条件 —— `sidebarRightTabs.register()` 注册的是**标签类型**,而类型是**应用级**的(注册一次,任何会话展开空面板时,引导页都会列出它的 `guide` 文案);`apply()` 里注册=插件一加载就全局可见,跟当前会话是不是 dm 毫无关系。(顺带把用户提的「GenUI 面板」与这个分开核实过:GenUI 的 panel dock 是**会话级**的 —— `panel-store` 按 `sessionId` 存内存 + localStorage,dock 挂在 `conversation.input.dock`,那次判断是对的,本次没动它。)改法:把注册搬到**唯一权威的 dm 判定**上 —— `RpHeaderButton`(`conversation.session.header.utilities`,非 DM 返回 null)里 `useEffect(() => (isDm ? acquireRpSidebarTab() : undefined), [isDm])`,`acquireRpSidebarTab()` 是模块级**引用计数**(`{dispose, refs}`:第一个引用注册、最后一个释放注销),`apply()` 里原来的 `ctx.inject([...], registerSidebarTab)` 删掉。为什么要计数:`tabs.register` 同 id 重复注册**会抛错**,而提前注销会让还活着的引用丢掉标签;运行时注册/注销本身是受支持的用法(`dsh-context` 自己的 placement 开关就这么做)。**测试基建先修真缺陷再测**:为了让「注销」可被断言,发现桩子三处与真实 Cordis/React 语义不符 —— ① `resetHooks()` 清空前**不跑** `rt.cleanups`(等于组件永不卸载);② `useEffect` 桩既不在依赖变化时跑上一次的 cleanup、也不把 cleanup 存回 cell;③ `ctx.inject` **直接丢掉**回调返回的 cleanup,且 slot / tab 注册桩不返回可用 disposer。修完后新增一组断言:apply 后**不注册** → DM 头部渲染后注册且引导页有条目 → 切到非 DM **释放** → 重复渲染**不重复注册** → 卸载释放。**诚实边界**:入口现在依赖「会话头部被挂载」(头部属于对话视图;若某处 dm 上下文不渲染头部,标签就不出现 —— 这是拿「可见性」换「准确性」的自觉取舍,不是遗漏)。测试:smoke-client(含上述新断言)通过、smoke-dm **983**、smoke-card 256、verify-roundtrip 10。**983 而非上一轮的 984 与本次改动无关**:唯一差别在 LoRA 清单块 —— ComfyUI 在线时多跑一条「每项有 name/krea2 字段」,现在本机连不上 ComfyUI,走离线分支(结构正确 + 明确报错),少 1 条。 **【补记 · 同一版本,提交 0544dc3】上面这一版修复在真机上完全没生效**(用户报「开过 DM 预设的会话之后,别的会话里入口还在」)。原因不是判定逻辑,而是**清理契约**:`ctx.inject(deps, cb)` 的真实返回值是**带 `dispose()` 的 handle**,不是裸 disposer 函数;旧代码只认 `typeof === 'function'`,于是 `dispose()` 从来没被调用 → 应用级页签类型注册后**永久残留**,跟「当前会话是不是 DM」已经无关。权威依据是出厂的 `dsh-context` 客户端:`const handle = ctx.inject([...], cb)` … `return () => { handle?.dispose?.(); }`;而 `slots.inject(...)` 与 `tabs.register(...)` 返回的**确实是裸函数**(`own()` 只收函数是对的,别一起改)。**更值得记住的是测试为什么没拦住:桩把错误契约抄了下来** —— 早先的 `ctx.inject` 桩把返回值伪造成函数、还替代码跑了回调的清理,于是「撤销」在桩里畅通、在真机失效,**测试越绿越危险**(与 1.14.0 那条「键根本不存在」的死断言同一类错误:**桩不是实现,是契约**)。现在桩返回 `{ dispose() }` 并在 dispose 时跑回调返回的清理,且**这条防线可验证**:把 `disposeInjectHandle` 退回只认函数的写法,套件会以 `AssertionError: 切到非 DM 会话应撤销 RP 页签类型` 失败(在临时副本上实测)。验证:smoke-client 通过(新契约桩)、smoke-dm 983/0、smoke-card 256/0。**部署注意**:安装时间 22:25:17 晚于当时服务器的启动时间 21:34:31 —— 文件装对了,但**还没进正在跑的那个进程**,必须重启 `dsh web` 才生效。 | | **1.14.1** | 落地「轻量 DM 地图」方案的**插件侧三件**(方案见 `docs/` 讨论;用户选「先做这三件」)。先做了**端到端实测**,两条路线对照:**A=把动态状态放 `rp_state.flags`(方案初稿 §2/§3.2)走不通** —— `applyStateUpdates` 写旗标是 `String(value)`,`map_edge_states`/`map_tokens` 从**第一回合**起就是 `"[object Object]"`(写进去了、不报错、永久丢失),数组被压成逗号串,再叠 `FLAG_VALUE_CHARS=120` 截断(30 节点已揭示列表 229→121 字)与 `FLAGS_MAX_SHOWN=16` 显示上限(20 个地图键把 `伏笔_黑猫` 挤到对 DM 不可见);**B=状态放自己的 JSON 文件 + 宿主注入一行摘要**可行,实测 64 字/轮、跨轮原样读回。据此做了:① 新增 `lib/rp-map.js`(只读模块:`loadMap` / `validateMap` / `validateState` / `renderMapContext` / `walkableFrom`),**不加 `rp_map` 工具** —— 两个文件仍由 DM 用通用 read/write 读写;② `buildTurnContext` 在「本场当前状态」之后注入一行地图摘要(**放 turn context 而不是 standing**:它随移动/揭示变化,进系统提示会让每轮前缀失效);`buildStandingText` 只在**已有静态图**时给一行文件指针(两个绝对路径 + 「运行时变化不要写回静态图」+「位置每轮自动注入,不必读文件」);③ 把 `rp-map.json` / `rp-map-state.json` 加进会话包名单(实测此前**两个都不进包** —— 名单是写死的,整张地图会从导出与快照里静默消失)。**两条渲染纪律都是先写错再改对的**:`**文件坏了 ≠ 还没建**`(状态文件存在但 JSON 坏掉时不能说「还没初始化」,否则 DM 会覆盖一份本可救回的文件);`**坏数据不渲染摘要**`(`node="nope"` 时只出 ⚠ 行 —— 拿垃圾状态画出一行看起来正常的地图,比什么都不显示更危险)。另外实测发现方案 §6.2 的示例按钮**违反它自己的 §7.2 校验**(队伍在厨房时 `map_unlock_hall_cellar` 会被判「两点不相邻」)—— 已写进 persona:按钮只从**当前节点的邻接边**生成。persona 新增「## 地图(可选)」小节(何时建图 / 不硬造 / mermaid 节点 ID 必须 ASCII / action 命名 `<地图id>:move:<节点id>` / 走 `render_ui` panel 原地更新 / 收到 action 先校验再写状态)。测试:smoke-dm **984**(+56:地图渲染 7 个分支、校验引用完整性、`walkableFrom` 排除未揭示与锁着、standing/turn 接线、备份与还原;预设 9 条)、smoke-card 256、smoke-client、verify-roundtrip 10 —— **全绿**。 | | **1.14.0** | 用户给的四项路线图里挑了 **P0 + P1**(P1 选「ZIP 单文件」)。**P0 不是「可以更安全」,是「照文档用就会丢数据」**:`rp_state` 的 `party` 说明写的是「只列**你要更新的人**」,实现却是**整组替换** —— 实测提交 `party:[{character:'祁俊',conditions:''}]` 之后,祝婉宁整行消失、祁俊自己的 `status` 与 `inventory` 也没了。现在**文档就是实现**:默认 `merge`(按 `character` 找到那一行,**只改你传的字段**,传空串=清掉该字段,没传的原样保留,找不到就追加);`partyMode:"replace"` 是「要删人/要重排顺序」时的显式出口;`party:[]` 两种模式下都清空(沿用原来承诺的语义);新增 `partyRemove`(按名字删人,可与上面叠加)。merge 时缺 `character` **报错**而不是塞一行删不掉的匿名记录。顺带修掉一条**死断言**:原测试传的是顶层 `conditions:''`(顶层根本没这个键),`'conditions' in state` 恒为 false —— 测了个寂寞。**P1 会话包**:新增 `lib/zip.js`(STORE-only 读写,零依赖;`crc32` 从 `png-thumb.js` 抽成 `lib/crc32.js` 共用,避免两份实现走散)、`lib/session-bundle.js`(`MANIFEST.json` + `session.json` + 世界书 + `assets.json` + `assets/**` + `cards/**`,逐文件 sha256;**不装** `snapshots/` 与用户自己放的文件)、四个路由(`export` 下载 / `snapshot` 拍快照并**只留最近 5 个** / `snapshots` 列表 / `import` 导入)。导入**默认不覆盖**(409 + `needsOverwrite`),确认后带 `overwrite:true` 重来且**覆盖前自动拍快照**;`session.json` 的 id 会改写成目标会话(其余相对路径天然可搬)。**安全**:zip 是外部输入,条目名逐段判定 `..`/绝对路径/盘符(`safeEntryName`),落盘时**再做一次**目标路径前缀校验(两道锁),清单 sha256 逐个核对,非 STORE 的包明确报错而不是给乱码。顺带发现并修一个**本机实测的真坑**:Windows + Node 24 下 `rmSync(path,{force:true})` 对**中文文件名会静默什么都不做**(不抛错、文件还在;同路径 `unlinkSync` 正常,ASCII 名正常)—— 而这个插件的卡面文件名**就是中文**(来自卡名)。于是新增 `removeFileVerified()`:所有删除**以磁盘状态为准**,`rmSync` 不行就 `unlinkSync` 兜底;用在资源删除(`fileGone` 不再由「没抛错」推出)与快照修剪(删不掉的报进 `failed`,不再谎报 dropped);快照列表也只认自己写的 `snapshot-*.zip`,用户放进该目录的包既不算快照也不会被删。测试:smoke-dm **928**(+79:P0 语义十七条含「不动队友/不动同行其它字段」、会话包二十条含 zip 往返与 CRC/截断/zip-slip 拒绝、快照修剪与中文名用户包不被删、导入默认拒绝→确认覆盖→自动兜底、删除验证三条)、smoke-client(+13:导出是 ``、拍快照会 POST、恢复点清单、导入 409→确认→带 overwrite、取消确认时**不发**第二次请求)、smoke-card 256、verify-roundtrip 10 —— **全绿**。 | | **1.13.2** | 第二次真机复测(`RP_PLUGIN_TEST_REPORT_RETEST.md`,结论「通过」)复核后修的四处。复测列的 3 条低风险问题**全部成立**,逐条复现过:① **`rp_table remove` 回显陈旧**(RETEST-01):`const tables` 捕获的是旧数组,`remove` 分支把 `session.tables` 换成 `filter` 出来的**新**数组,而回显仍读旧的那个 —— 响应说「已删除表格」紧接着又列出刚删的那张(删除逻辑一直是对的,错的是回显)。改成 `let` + 回显新数组,note 写明删了哪张;顺手把「删一张不存在的表也回「已删除」」改成报错(与 `roll` 一致,列出实际有哪些表)—— 同一类问题:回显与事实不符。② **`rp_scenes` 的 `sceneId` 字段契约**(RETEST-02):场景 id 只认 `scene_id`,文件里写成 `id` 时筛选**静默返回 0/0 且 ok:true**,看起来像生图服务没输出;而且归档进资源库的标签会退化成 `… · ?`(真机那两张场景图就是这么存进去的,索引里的问号永远留着)。现在:工具说明给出**最小合法 JSON 示例**并点明 `scene_id` / `panel_id`;筛选用 `sceneIdOf()` / `panelIdOf()` 兼容 `sceneId`/`id`/`title` 别名(约定字段优先);**筛不到直接报错**并列出文件里实际有哪些 id、第一条场景对象的字段名;「有幕但没有 panels」也报错。③ **`rp_assets` 错误信息指错**(RETEST-03):`get` 一个不存在的 id 回的是「**需要 id**」,与「没传 id」完全同一句 —— 调用方明明传了,只能怀疑自己。现在两句分开,并带上现有条数与前几个 id 便于自查;`tag` 同口径。④ **M-3 的真正修法(复测未覆盖,复核里提出)**:上轮是**警告** DM 别找错地方,但根因是**插件让 DM 去找一个宿主本来就知道的路径** —— launch 文件路径只在导入那一刻的返回与开场白里出现过,既不在会话配置里也不在常驻段里,DM 到了后续轮次只能 glob,而真机日志里那次 glob 捞到了 **4 个别会话的** launch 文件。现在常驻段新增 `【开局文件】<绝对路径>`(没有就明说「本会话没有」并提示「不要在别处找别人的」),只扫本会话的 `cards/*.launch.md`;persona 里那句「去找 launch 文件」改成「**不要用 glob 去找**,宿主已经把两行事实印在系统提示里了」。⑤ **资源库内容去重**(报告未提,从复测环境里看出来的):两张场景图的 md5 完全相同(复测把同一格生成了两次)—— 库里出现「同字节不同 id」的两条,图墙看着两张、磁盘两份、人分不出区别。现在按 `sha256` 去重:同分类同字节**复用已有条目**、合并标签、不写第二份文件;查重放在**写锁内**(锁外查会双双通过,8 个并发归档同一张图会变成 8 条);索引补 `sha256`,老条目只在「字节数相同」的候选上补算一次。测试:smoke-dm **824**(+44:RETEST-01 七条、RETEST-02 十一条含「把 ComfyUI 指到空端口,证明筛选确实命中而不是静默 0/0」、RETEST-03 六条、launch 八条含「不会把别的会话的 launch 列进来」、去重十二条含同字节并发,外加骰子那批回归)、smoke-card 256、smoke-client、verify-roundtrip 10 —— **全绿**(这一轮的临时 profile 重建过:上一轮 PowerShell 的 `Copy-Item -Recurse` 把 `lib` 套成了 `lib\lib`,差点让测试跑在旧代码上,现已确认 4 个文件平铺、无嵌套)。 | | **1.13.1** | 真机测试报告(会话 `afdca15e`)复核后修的三件事。报告列了 5 条缺陷,逐条对日志核验的结论是:**BUG-03/04/05 成立,BUG-01/02 是误判**(把 persona 里的规矩读成了本会话的既有状态 —— 初始注入里其实只有「生图:开」和一行世界书**路径**,没有任何人物/表/图;整个会话的 `rp:turn` 运行时快照数为 0,也就是世界书正文一次都没注入过)。① **骰式解析改成整串校验**(BUG-03):旧实现用 `String.match(/…/g)` 做**扫描**,匹配不上的字符被静默跳过 —— `2dd6` 被吃成「常数 2 + d6」还照原样回显(note 说 2dd6、明细却是 1d6,自相矛盾),`2d6++3` 里那个多余的 `+` 也被跳过;现在用**粘性正则逐段吃**,必须刚好停在末尾,剩下一个字符就抛错(`2d6+`、`2d6+3+`、`2d6x3` 一并挡下)。顺手补了**骰面上界** `DICE_MAX_SIDES=100000` —— 报告没发现的 `1d1000000000` 以前是「合法」的,会掷出十位数。② **`count` 越界抛错,不再静默截断**(BUG-04):旧实现是 `Math.min(Math.max(trunc(c),1),20)`,而契约写着 1..20 且「参数非法会抛明确错误」,实际 `count:21` 只给 20 个、`count:0` 悄悄变 1 —— 模型以为掷了 21 次,跑团里就是结果与叙述对不上。抽成 `resolveRollCount()`,`rp_random` / `rp_table` / `/rp-tools/roll` **三个入口共用**(否则两个入口行为不一致,下一份报告还会抓到)。③ **每一掷都带可核对的明细**(BUG-05):根因不是报告说的「复杂骰式」—— 是 `count > 1` 时一句 `out.note = …` 把明细覆盖了,简单的 `2d6+3 count:3` 一样丢。现在每掷都输出 `11(2d6[6,2] + 3)`,并顺手修好明细里**从不出现常数修正**(负骰组也不带负号)的老毛病 —— 以前 `2d6+3 → 11 (2d6[6,2])` 玩家一加是 8,看着像骰子坏了;明细的意义就是能对上账。④ **persona 与常驻段**(BUG-01/02 的真实种子):报告作者是模型,它读自己的 system prompt 时把**约定**读成了**状态**。a) `glob **/*.launch.md` 在真机日志里一次捞出 **4 个别会话的** launch 文件(`890426ed`/`bdfffca2`/`6918d7fe`/`0bdde5e2`),读错就是拿别人的世界观开团且**不报错** —— persona 现在写明 launch 必须按【世界书】同目录去找,并点出根目录 glob 的后果;b) 空会话的注入里**什么都不出现**,很容易被读成「已经有了」—— 现在空就直说:`【人物】本会话还没有登记任何角色`、`资源库:本会话**还没有任何图**`、`【世界书】…**这份文件还不存在**`(文件存在后自动换回正常措辞),并加一句「常驻条目 / 已有可用图是**规则说明**,本会话有没有看它下面列的东西」。⑤ **补上随机核心的测试**:这块此前**零覆盖**(原 730 条断言里没有一条碰过 `parseDice`),所以报告 5 分钟就抓到的东西一路上了线。新增 44 条,全部用报告里的**具体输入**:`2dd6`/`2d6++3`/`2d6+`/`1d1000000000`/`count:21`/`count:0`/`count:2.5`/`count:2` 保留明细/总和能对上账/三个入口一致,外加空会话常驻段四条与 persona 四条。测试:smoke-dm **780**、smoke-card 256、smoke-client、verify-roundtrip 10 —— **全绿**。 | | **1.13.0** | 用户要的「资源库」:**「dm 可以浏览资源,选择在合适的适合调用显示;生成过的图片或外部图片都保存下来,可列出查看和显示」**,并追加「资源要分类,按场景、角色、道具等分内部文件夹」。查下来现状是**只有立绘留档**:场景图/群像图/道具图/`rp_scenes` 整幕多格出完就只在当轮消息里,宿主侧一个索引都不写 —— 想让「雨夜客栈」再出现一次只能重新抽卡。三件事一起做:① **真存一份**(不是记三要素引用)—— 出图后从 ComfyUI `/view` 抓字节写进 `<工作区>/rp-sessions//assets/<分类>/.` + `assets.json` 索引;引用式过一阵子会全是死链(ComfyUI 的 `output/` 会被清)。分类 `portrait|scene|item|other` 各一个子目录,**索引里的 `kind` 就是目录名**。② **DM 侧浏览**:新增 `rp_assets` 工具(`list` 按 `kind`/`characters`/`tags`/`q`/`limit` 查,`get` 看详情,`tag` 事后补标签;**不能删**),`rp_illustrate` 加 `label`/`tags`/`kind` 三个参数(不填就等于这张图以后找不到),`rp_scenes` 加 `label`/`tags` 且整幕共享一个 `group`;常驻段**只报条数**(「资源库:本会话已有 N 张图(角色 a、场景 b、道具 c)—— 先用 `rp_assets` 按标签或角色查一遍」)—— 常驻内容每轮都发,把上百条列进来是白烧几千字。③ **玩家侧面板**新增「资源」卡片:分类筛选(分类清单由宿主给,界面不自己维护映射)+ 搜索 + 图墙(`thumb=1&width=480` 服务端降采样,**不原图直出**)+ 详情浮窗(改名称/标签、**显示到对话**(拼 `dsh-ui` 围栏填进输入框、不自动发送)、**设为某角色的立绘**、**删除**)。导入升级成通用 `/rp-tools/asset-upload`(`/rp-tools/portrait-upload` 保留为别名),角色编辑器那个入口现在也进库。**两个坑写进代码注释**:(a) `assets.json` 是 read-modify-write 而 `rp_illustrate` 并发安全(并行池最多 10 个在飞)→ 所有写索引的路径都过 `withAssetLock` 的**按会话串行写队列**(没有它「出了 5 张只入库 2 张」且不报错);(b) 「设为立绘」必须**清掉该角色旧的 `generated`** —— 读取端 `portraitsFromSession` 优先 generated,留着它用户点了没反应(静默失效)。删除资源会连磁盘文件一起删,并**自动解除指向它的立绘引用**(不留死链)。顺带修:`ASSET_*` 常量与 `assetKindOf` 必须放在 `rpTools` **之前**(`rp_scenes`/`rp_assets` 的参数说明引用了它们,而数组字面量在模块加载时就求值 —— 放后面直接 TDZ 抛错,和 `__debug` 那个坑同一个成因)。测试:smoke-dm **729**(+96:分类/目录/标签解析、归档与索引、**20 个并发归档一条不丢**、四种过滤、列表接口与分类清单、取图与缩略图与越界拒绝、改标签/删除/设为立绘(含清 generated)、通用导入、`rp_assets` 三种 action、**归档触发路径打桩 `fetch`**(抓到→入库 / 抓不到→不抛也不入库 / 无 files→不发请求)、常驻段只报条数、预设四条)、smoke-client(+30:图墙与缩略图地址、筛选与搜索、「全部」报库总数不随筛选变、导入分类与 data URL、详情浮窗与三项动作、**显示到对话不自动发送**、无资源时整卡片不渲染)、smoke-card 256、verify-roundtrip 10 —— **全绿**。 | | **1.12.10** | 用户五条一起提:① **立绘默认纵向**(「在角色卡编辑界面立绘就会大点」)。新增 `illustrateSizeKey(session, prompt, args)`:`rp_illustrate` **不传 `width`/`height`/`aspect`** 时,用已有的 `characterInPrompt` 看画面里是不是**只提到一个已登记角色** —— 是、且 TA **还没有立绘**(`generated`/`imported`/`card` 都没有)→ 走 `portrait` 档(纵向 512×768,本来就是立绘该有的比例),否则 `scene`;调用方显式给了尺寸就一律不干预。面板的「立绘」走 `/rp-tools/preview` 时同样传 `sizeKey: 'portrait'`。② **去掉「看大图」**:浮窗左侧那张图现在就是最大尺寸(`max-height` 由 `calc(85vh-240px)` 放宽到 `calc(88vh-250px)`),再开一个标签页没意义。③ **「删掉立绘」→「重新生成」**:不满意应该覆盖那张,而不是把记录删掉留一个空位(清理由**删角色**兜底 —— 原来那条 `clearPortrait` 覆盖测试挪到删除按钮上,没丢覆盖)。④ **支持用户外部导入立绘**:新增 `POST /rp-tools/portrait-upload`(data URL → `<工作区>/rp-sessions//portraits/.`,只收 png/jpeg/webp、上限 8MB,文件名由 `portraitFileSlug()` 生成 —— 用户给的名字不进路径)与 `GET /rp-tools/portrait-image`(**只发会话配置里登记过的那张** + 会话目录前缀校验,双锁挡路径穿越;URL 带 `v=<导入时间>`,同名重导才不会被浏览器缓存挡住)。面板:`label.filebtn` 包一个隐藏的 `` → `FileReader.readAsDataURL` → POST;`portraitsFromSession` 也认 `imported` 那份,常驻段【本会话设定】的「已有可用图」把它标成「(导入的立绘)」。⑤ **DM 叙事时可以直接用角色立绘**(persona 插画节 + 常驻段口径):零成本展示「已有可用图」里的图,不必为了「让角色露个脸」重新生图;同时写明**已有立绘不会被覆盖**(不满意让玩家用面板按钮换,别靠反复出图去撞)。顺带:`illustrateSizeKey` / `portraitFileSlug` / `saveSession` 进 `__debug`(`portraitFileSlug` 从 `installRoutes` 内部提到模块级 —— `const` 在函数里,`__debug` 引用会 TDZ 抛错)。测试:smoke-dm **634**(+32:尺寸档 8 条含「已有生成立绘/导入立绘 → scene」、导入立绘 20 条含跨源/类型/越界路径拒绝与字节一致、常驻段 3 条、预设 4 条)、smoke-client(+12:无「看大图」、无「删掉立绘」、有「重新生成」、file input 的 accept、真的走一遍导入 POST、`sizeKey=portrait`、导入立绘按 `/rp-tools/portrait-image` 渲染且带 `v`、删角色兜底清理立绘)、smoke-card **256**、verify-roundtrip 10 —— **全绿**。 | | **1.12.9** | 用户补了一句关键的:「角色卡那里不是有个立绘吗 —— 第一次角色出场如果生成了立绘,可以给角色卡用」。查下来确实是个断链:**只有面板上点「立绘」才落盘**,DM 用 `rp_illustrate` 出的图只活在聊天里 —— 角色行还是占位头像、下一轮【本会话设定】的「已有可用图」里也没有它,于是 DM 很可能又出一张。新增 `recordGeneratedPortrait(sessionId, name, files, {style, elapsedMs})`:`rp_illustrate` 出图后,若画面里**只认得出一个已登记角色**(复用已有的 `characterInPrompt`,群像/场景图返回空 → 不记)且这个角色**还没有立绘**,就把三要素 + 风格 + 耗时写进 `portraits[name].generated`(**不覆盖已有的** —— 面板里那张、用户自己配的那张都保住);`rp_character` 的 `portrait` 出图同样登记。工具返回的 note 会写明「已记成「X」的立绘(下次直接用这张,不必重出)」,下一轮的常驻段立刻能复用它;面板侧不用改 —— 一轮结束时本来就有的软刷新会把 portraits 拉进来。persona 插画节补一句「给某个角色出的第一张图会自动记成它的立绘……群像/场景图不会」。测试:smoke-dm **597**(+11:写入/三要素/风格耗时/下一轮可复用/已有立绘不覆盖/认不出角色不记/无文件不记/预设说明)。 | | **1.12.8** | 既然并发省不掉 GPU 时间,就照用户说的动**真正的杠杆**:**降尺寸** + **立绘复用**。① **降尺寸**:出厂 `DEFAULT_IMAGE_SIZES` 由 scene 1024×576 / portrait 640×896 / item 768×768 调到 **768×432 / 512×768 / 512×512**(≈0.33 / 0.39 / 0.26 MP,按本仓库实测的时间-像素关系,单张从 15~25s 降到大约 **8~14s**);三个内置风格的 `sizes` 与客户端的内置默认值同步。**迁移只动「没动过」的**:老配置里三档还等于旧出厂值(`LEGACY_DEFAULT_IMAGE_SIZES`,逐档判断)就换成新值,自己调过尺寸的那一档原样保留(§9 不替用户猜);风格级 `LEGACY_STYLE_SIZES` 也补上了旧出厂值。② **立绘复用**:常驻段【本会话设定】新增/改名一行「**已有可用图:先在下面找,有就直接展示,不要再生成**」—— 除了已有的生成立绘,**导入卡的卡面也会被列成可用图**(对角色卡来说那张 PNG 就是它的立绘),归属判定:卡名与某角色同名 → 归它,只有一个角色 → 也归它,多个角色又对不上名 → 只当一张卡面列出(不硬塞给谁);已经有生成立绘/已登记卡面时不重复列(免得 DM 以为有两张图)。persona 插画节同步加「先查「已有可用图」,能复用就不生成」。测试:smoke-dm **586**(+14:降尺寸迁移 6 条 —— 旧值换新、自定义那档不动;立绘复用 6 条含归属/不硬塞/不重复;预设 2 条)、smoke-client(内置默认值断言同步)。 | | **1.12.7** | 用户:「生图比较慢,提醒 DM 并行处理」。查下去发现**光提醒没用** —— 宿主的 `tools.executionMode()` **只认显式声明**:`isConcurrencySafe(args) === true` 才进并行池(默认上限 10),**没声明、未定义、抛异常一律 exclusive**,也就是说模型就算在同一轮里发三个 `rp_illustrate`,宿主也会一张一张串行等(每张 15~25 秒)。所以两件事一起做:① `rp_illustrate` 与 `rp_scenes` 加 **`isConcurrencySafe: () => true`**(它们只读配置/会话,然后 POST /prompt 拿 promptId 轮询 `/history/`,各自一个 promptId、无共享可变状态;ComfyUI 侧本来排队,并行只是把排队交给它,省掉的是模型往返与叠加等待);写会话/配置的工具(`rp_session`/`rp_character`/`rp_config`/`rp_state`…)**故意不声明** —— 它们是 read-modify-write,并行会丢更新(宿主的 fail-closed 默认正好兜住)。② 提醒写进两处:persona 插画节新增「**一次要几张就一次发几个调用**」(含「串行会把等待叠成几倍」「整幕用 `rp_scenes` 一把出」「多张图放同一个 `dsh-ui` 围栏」),以及每轮都在的常驻段【本会话设定】生图行追加同一句。测试:smoke-dm **572**(+10:并发 6 条 —— 两个出图工具声明为 true、**参数不合法时退回串行**、三个写工具不声明;预设 3 条;常驻段 1 条)。 | | **1.12.6** | 用户对着导入面板截图:「左侧列表长一点」。原因:`.rpc .list` 封顶 `max-height:300px`,而卡片只有几张时高度就是内容高度(一百多像素),右边预览却有七百多 —— 右列一大片空、左列挤成一条。改成 **左列表与右侧预览同高**:`.rpc .split` 的 `align-items` 由 `start` 改 `stretch`,`.rpc .list` 改 `height:100%; min-height:320px; max-height:min(78vh,820px)`(卡片多时在列表内滚动,面板不会无限长)。测试:smoke-client +4(两列 stretch / 列表 ≥320px / 上限 min(78vh,…) / 不再封顶 300px;做过变异验证:min-height 改回 120px 立即报错)。 | | **1.12.5** | 两个改动。① **立绘放大**(用户:「立绘要大,最好利用完左侧空间」):角色编辑器左列由封顶 `minmax(200px,300px)` 改成 **`minmax(280px, 46%)`**,图片同时受 `max-width:100%` 与 `max-height: calc(85vh - 240px)` 约束(宽高都 `auto` → 只等比缩小、不变形),典型 3:4 立绘从 280×400 变成约 464×678;窄屏(≤860px)退回单列 + `max-height:60vh`。② **技能/能力/装备归位到状态层**(用户一句「技能/能力/装备 等也可能是动态数据」点破了「技能当静态设定放人物卡」的错处):`PARTY_FIELDS` 由 `character/status/inventory/conditions/goal` 扩为 **`character/status/abilities/inventory/conditions/goal`**,新增显示名「能力/技能」,`inventory` 的显示名改成「持有/装备」——**学新技能、被封印、临时增益、换武器、用掉道具都更新这一行**(每轮注入、过期即被覆盖);人物卡只留不会变的底色。配套:`rp_state` 的 `party` 参数说明写明这是动态值、别写回人物卡;dm 预设新增「## 状态:会变的东西一律进 `rp_state`,人物卡只写不变的底色」小节(含数值放 `flags`,如 `{"桐人_HP":"1744/2500"}`);launch.md 的收尾清单①加了同一条(开局把卡里的技能/装备写成 `rp_state` 初始值);面板「当前状态」的队伍行显示「能力:…」。测试:smoke-dm **562**(+4:能力/持有落盘、注入文本带「能力/技能=」与「持有/装备=」,并复用已有的「队伍字段都有显示名」断言兜住漏标签)、smoke-client(+3:队伍行显示「能力:…」「持有:…」)。 | | **1.12.4** | 用户对着角色卡截图指出:有立绘的那一行被撑成三倍高(截图里的「桐人」)、旁边还挂着「大图 / 收起」两个按钮,而且**「收起」其实是把立绘从会话配置里删掉** —— 「收起之后反而看不了了」。于是按用户给的方向重做角色行:**一行 = 36px 小头像 + 名称 + 简介 + 标记 + 编辑 / 立绘 / 删除**。① 头像固定 36px、`object-fit: cover`;没有立绘时给**名字首字占位**(同一位置、行高不变),卡面不当头像(它仍是世界设定旁的封面);② 列表里**删掉「大图」与「收起」**(`.charface` 那套 150×200 的两列布局与 `.charbox[data-hasface=true]` 栅格一并撤掉);③ 删立绘挪进**编辑浮窗**(左列大图下面「看大图 / 删掉立绘」)—— 那里看得见大图,删之前不会像以前那样「点完才发现没了」;④ 出图后的提示文案从「图在该角色的卡片下方」改成「已作为这个角色的头像显示」;⑤ 删角色时顺手清掉它的立绘记录(原来那段内联清理逻辑抽成已有的 `clearPortrait()`)。测试:smoke-client 断言 36px 头像 / `object-fit:cover` / 列表里不存在「大图」「收起」/ 无立绘时有占位 / 头像图带三要素 URL / 「删掉立绘」走 `portraitSave{action:'clear'}`(做过变异验证:把 36px 改成 40px 立即报错)。 | | **1.12.3** | 用户两次更正诊断口径,最终定为:**「键不用管,只判断条目名称是否重复」**。① 先去掉 `duplicateKeys`(条目之间共用一个触发词只是多命中一条,不影响正确性,标一屏「重复 key」纯噪音);② 再把判定收成**只看条目名** —— `loreKeyDiagnostics()` 改名 `loreNameConflicts()`,不再把触发词算进来(「条目叫『室友』、键是『诸葛大力』」不算重名),装配层 `activateLore` 的 `skipTitles` 也**只比条目名**。判定用 `normalizeLoreKey()` 归一(全半角/标点/大小写),否则「 长安城:」与「长安城」会被当成两条。面板:全局提示「⚠ 与人物卡重名的条目:… —— 正文已由人物卡承载,世界书不会再重复注入」,条目徽标「与人物同名」;接口里条目字段 `duplicateKeys` → `nameConflict`,且不再把内部查表 `byTitle` 整片 spread 进响应。测试:smoke-dm **558**(诊断为「不再产出 duplicateKeys / 只有条目名 == 人物名才报 / 条目名不同而触发词撞人物名 → 不报 / 归一后仍算重名 / 空壳不参与」;规划侧补「触发词撞上人物名不算重复」)、smoke-client 断言**页面上不存在「重复 key」字样**且存在「与人物同名」徽标。 | | **1.12.2** | 用户拍板:**「初始要做的事」全部放进 `cards/.launch.md`,不要为它们再在面板上加按钮**。于是把「属性中文化」「重命名无名条目」两个按钮**删掉**,把这件事交给 DM:① `buildTidyPrompt()` 新增 `when: 'launch'` 语气,并**整体嵌进开局引导文件**(launch.md 从 1.0k 涨到 ~1.7k 字,读一次就拿到全部开局任务:选定开场 → 文件清单 → 角色字段归位 → 世界书过滤 → **③ 标题与文字按当前语言收拾**(`name:` → `名称:`、`gender: Female` → `性别:女`;`条目 4` → 「战斗」这类实义名)→ 收尾一行摘要);② 为让 DM 真的做得到,`rp_lore` 新增两个动作 **`localize`** 与 **`rename_unnamed`**(与 HTTP 路由共用 `applyLoreEdit`),DM 一次调用就能把整本收拾完,不必手改十几条;③ 客户端删掉对应函数与按钮(面板世界书卡片只剩「+新建条目 / 刷新 / 整理设定」)。理由记在代码注释里:那两个按钮只能做规则能判的那几件,剩下的(哪条该合并、哪条该改成状态)本来就得 DM 看着办,**同一件事两个入口反而让人以为没做**。测试:smoke-card **256**(+11:launch 含收尾清单/语言与改名两条指引/launch 语气)、smoke-dm **556**(+10:`rp_lore` 的 localize 与 rename_unnamed,含幂等与「来源标记/触发词/正文不动」)、smoke-client(改为断言两个按钮**不存在**)。 | | **1.12.1** | 用户对着新会话截图提了四条(**针对插件本身,不是某个会话**)。① **作者注里的广告**:【世界设定】里塞满了「本角色卡分享于破限组交流群:704819371…」「CC BY-SA 4.0 许可证…」。新增 `stripPromo()`:**逐行**判定社群/引流、转卖/侵权、授权/版权、联系方式四类噪音,只作用于**元信息字段**(`creator_notes`),不碰 `description`/世界书正文;一半说明一半广告时说明留下、整段是广告时【作者注】整块不进世界设定(全文仍在 `cards/.md`),摘要里报「去掉 N 行」。真卡实测 369 字 → 172 字、去掉 2 行。② **无名条目只剩编号**:面板里 17 行「条目 4」…「条目 20」认不出谁是谁。新增 `loreTitleOf()` 四级回退:卡给的名字 → 正文里第一个 markdown 标题(代码围栏内的不算,那是卡画的面板)→ **触发词**(`--战斗` → 战斗、`--详细地图` → 详细地图)→ 正文首行 → 实在没信息才编号;`uniqueTitle()` 保证不重名(世界书按标题合并/触发,重名会互相吃掉)。真卡 20 条全部变成 `Basic needs / Status / Key info / 战斗 / 营地 / 详细地图 / 更新地图 / 地图 / 扫描 / 回收 / …`。③ **面板**:角色缩略图 150×200 → **88×118**(用户:太大,一行占满屏);**去掉「+添加角色」**(只通过 DM `rp_character` 加),换成一行说明;角色大浮窗改成**左列大立绘(200–300px,sticky)+ 右列字段**的两栏版式。④ **补一个一次性动作** `renameUnnamed`(世界书卡片「重命名无名条目」按钮):把已导入的老世界书里的「条目 N」按触发词改名 —— **只动** `/^条目\s*\d+$/` 这类占位标题,用户自己起的名字、正文、`keys`、`source: card` 标记一字不动,幂等。测试:smoke-card **245**(+17:标题四级回退/重名/广告过滤端到端)、smoke-dm **546**(+11:renameUnnamed 路由/保留 keys 与来源/幂等)、smoke-client(+12:按钮存在与 POST、缩略图尺寸、无手加角色、编辑器两栏与左列立绘 —— 做过变异验证)。 | | **1.12.0** | 按 `docs/DEVELOPMENT-PLAN.md` 做的分层注入与去重(验收发现的问题逐条返工)。① **世界书边界**:`parseLoreMarkdown` 由 `/^#{2,6}\s+/` 收紧为**精确 `^## `** —— 卡正文里的 `### 外貌` 不再各自变成条目(《爱情公寓》实测:**29 个顶级条目 == 29 个解析条目**,此前是 29 vs 44),随之 standing 里被重复计入的正文也消失。② **分层与预算**:新增 `planLoreInjection()` + `STANDING_LORE_BUDGET_CHARS=6000` —— **导入来的条目(`source: card`)不再因 `constant` 自动获得 system 权限**,超预算的手写常驻同样**降级**(不截断、不残句),改按触发词进 runtime,并在 standing 里列出「按需」标题;导入器给每条写 `source: card`,`renderLoreEntryBlock` 原样保留它(编辑一条导入条目不会把它「变回手写」)。实测:同一张卡新导入后 standing **43,912 → 1,750 字**,其中常驻世界书 0 字;真机老会话(无标记)靠预算从 38,556 → 6,830 字。③ **装配层去重(§3.3)**:`activateLore` 收 `skipTitles`,命中的条目若正是**本轮要展开的人物**就跳过并记 `character-duplicate` 诊断 —— 真机会话 15 个人物**全部跳过**(此前同一轮里两份正文叠加 5.2 万字)。新增 `normalizeLoreKey()` / `loreKeyDiagnostics()`(全半角/标点/大小写归一)并接进 `/rp-tools/lore`:面板的「重复 key / 与人物卡重合」告警**以前读的是宿主从不返回的字段(死代码),现在真有数据**。④ **开局(§4)**:新增 `cards/.launch.md`(选定开场 + 文件清单 + 已写入什么 + 待确认 + 「不要再问世界从哪来」),首条开局消息缩到 **61 字**、只给路径;`buildTidyPrompt` 彻底变成用户显式触发。⑤ **`rp_session` 补齐** `dm_prompt` / `images_enabled` / `images_first_appearance` / `images_key_scenes`,改生图开关不再清空 DM 正文。⑥ **面板**:删掉底部重复的「生图配置(本会话)」卡(默认风格/试出各只剩一份);**世界书与角色卡改成单行列表 + 共享 portal 大浮窗**(Esc / 焦点恢复 / 未保存确认 / 固定保存栏)—— 此前 `SharedPortalModal` 定义了却没人用,世界书与 DM 的「编辑」按钮**点了没反应**(本轮修好);浮窗里的编辑会置 `dirtyRef`,宿主软刷新不再把没点「应用」的改动冲掉。⑦ **dm 预设(真机新会话暴露)**:导入的卡自带 ASCII「状态面板 / 战斗面板 / 连接面板」(`┌──═━┈`、`⑆ ♡ 5000/5000` 那类代码块),DM 在思考里明说「I'll follow the card's format (codeblocks)」就照抄了 —— 新增「**## 渲染方案:一律用 GenUI 组件,不要照抄卡里的画法**」小节:把卡里的面板当成「这一屏要显示哪些数据」的清单,改用 `keyvalue`/`progress`/`table`/`badge`/`stat`/`list` 重画;代码围栏只留给真的是代码/终端输出的场合、且优先用 `code`/`json` 组件;同一批数据只出一次;图片只用 `image` 组件、不许贴裸 URL;顺带把 persona 里那个**不成对的三反引号**(会让模型把后文当代码块)改掉。测试:smoke-card 228、smoke-dm **535**(+10:dm 预设的渲染方案/围栏成对/过滤开关断言)、smoke-client(含新的单行/浮窗/去重/规划断言)、verify-roundtrip 10 —— **全绿**;顺手修好测试桩:`document.body` 缺失(浮窗永远走「退回原位」分支)与 `useEffect` 不按依赖跑(每次 render 都重跑挂载时的 `reload()`,把刚改的 draft 冲回宿主版本)。 | | **1.11.2** | 用户对着面板截图报了三件事。① **「dm 填完后需要我手动刷新」** → 面板新增**软刷新**:订阅宿主官方那份 `useSessions().byId[id].running`(`api-session/status` 广播)与 `updatedAt`,DM 那一轮结束(running 下降沿)自动把宿主的改动拉进来;用 `dirtyRef` 保证**用户改过就不覆盖表单**,同一个状态签名只处理一次(防自激)。② **「角色卡第一条似乎写错了,这是 dm 预设吧」** → 确认:那张 `lust Adventure` 是 **DM(旁白)卡**(名字是模型名、正文通篇「<卡名> will …」、开场白在对玩家说「给我一个故事背景」),早先按角色卡导进了角色表。新增 `isDmCardData()`(强信号 + 三个弱信号里中两个;正常角色卡、以及「只在关系里提一句旁白」都不误判)与 `dmCardToPrompt()`:**DM 卡不再建角色**,正文写进新的 `session.dm.prompt`;`loadSession()` 还会**在内存里把老数据里这类角色搬过去**(用户那张卡实测:角色表只剩「弥珥·索兰」,dm.prompt 拿到 2398 字,界面会提示「已从角色卡挪到这里」——点保存才落盘)。③ **「添加 DM 设定面板」+「系统提示词加一条生图说明」** → 面板新增「DM 设定」卡片(DM 自己的规则文本 + 生图三个开关:总开关/角色首次出场/重要场景,**按会话隔离**存 `session.dm.images`);常驻段新增【本会话设定】块(生图开关、**已有立绘的可直接用地址**、DM 正文),dm 预设 persona 新增「## 插画(本地生图可用时)」:首次出场配立绘、重要场景配氛围图、拿到 `media[0]` 必须用 `dsh-ui` 的 image 组件显示、**已有立绘不重复生成**、开关以【本会话设定】为准。测试:smoke-card 220(+8:DM 卡判定 5 条含反例 + 分节正文)、smoke-dm 477(+19:DM 卡导入/迁移/生图开关读写/常驻段内容)、smoke-client(DM 设定卡、三个开关、迁移提示、软刷新接线断言) | | **1.11.1** | 用户拍板「可以在开局第一轮提示 DM 完善角色卡和世界书,或者干脆让他来填」(并确认「这样就可以过滤无关项了」)→ 新增 `buildTidyPrompt()`(`lib/card-import.js`):一条**设定整备**指令,**随开场指令一起发**(`buildOpeningPrompt` 的 `tidy` 段,默认开),已开局的会话则由面板「整理设定」按钮再发一次。内容:① 角色卡字段归位(`rp_character`:把挤在「性格」里的整段描述搬进 appearance / speech / behavior / relations / first_mes / mes_example,**只搬家、不缩写、不自己编**);② 世界书过滤(**删掉或改成触发式**:当前进度 / 前情提要 / 历史纪要 / 物品清单 / 好感现状 —— 它们是会过期的状态,交给 `rp_state`;删空壳条目;每条补 1-3 个玩家真会说的触发词;`constant` 只留 1-3 条核心;**玩家手写的条目与注释不要动**);③ 收尾只给一行摘要。新增 `POST /rp-tools/tidy` 让宿主给措辞(带上本会话世界书路径 + 条目清单,措辞只有一处),界面只 `setDraft` **不自动发送**(长指令先进输入框由用户确认)。**不为此起临时 agent**:卡全文已经在 `rp-sessions//cards/.md`。另外回答「关掉 `suppressRuntimeContext` 会不会引入别的插件的东西」:**不会引入第三方**,放进来的是宿主自带的 `sandbox:policy` 与 `approval:policy`(除 dm 外每个预设本来就有),唯一在册的第三方上下文是 `dsh-subagent` 的 `subagent:delegation`,它只在子会话。测试:smoke-card 212(+16:整备各条要求、`when:'now'` 措辞、`tidy:false` 关闭、条目清单)、smoke-dm 452(+7:tidy 路由内容/条目数/跨域拒绝)、smoke-client(按钮存在、点了 `setDraft` 宿主原文、**不自动 submit**) | | **1.11.0** | 用户问「我在系统提示词里没发现有开常驻的世界书条目注入,这是否是问题?」—— **是问题,而且是两处叠加的静默失效,注入在生产里一次都没成功过**(冒烟断言却 421 条全绿)。① **会话 id 取错**:`sessionIdOfCtx()` 读 `ctx.agent.id`,而 agent 作用域 ctx 上**根本没有 `agent`**(`createScope` 只 extend 一个 scope 标记;真实来源是装配上下文的 `context.agent` —— `assembleContextFor(agent)` 造的 `{agent, scope: agent}`)。`normalizeSessionId(undefined)` 落到 `'default'`,于是每轮都去读那个空会话:世界设定/角色卡/世界书全空,`_standing-probe.json` 里 `sessionId: "default"`、`loreEntries: 0`。测试桩之所以没抓到,是因为它**凭空给了 `ctx.agent`**(桩错了,不是代码对了)。② **dm 预设里 `suppressRuntimeContext: true`** 把 `rp:turn` 整条通道关掉了 —— 而「按关键词触发的世界书」正走这条通道。修法:会话 id 一律取 `context.agent ?? context.scope`(provider 也接 `(context)`),注册记账改成**作用域粒度**,`known` 改成**本次装配的 variables 快照**(宿主对未注册名与 undefined 值都直接抛错,而 variables 在 waterfall 之前收集);`session-filter` 改名 v2、`suppressRuntimeContext` 改成**显式 true 才关**,预设里显式写 false。顺带按用户预期把**常驻世界书条目移进 `rp:standing`(系统提示)**:内容稳定,没必要每轮往历史里再追加一份全文(10 条常驻 ≈ 4.5 千字),`rp:turn` 只留状态 + 命中条目 + 在场角色;`rp:turn` 注册文本改成**空串**(宿主会丢掉空文本 → 没有可注入内容就不产生快照消息)。世界书**智能过滤**:正文只有模板残留的条目(`1.` + 空 markdown 块那类)解析时标 `empty`、**不注入**(常驻也不注入),面板默认折叠并给一行「已隐藏 N 条空条目(M 字)」+ 展开清理开关;条目类别标签 设定/规则/状态/历史,**常驻的「状态/历史」条目额外标「常驻存疑」**(它们会过期,占着每轮上下文)。面板顶部新增「每轮都会注入:常驻 N 条 ≈ M 字/轮」(只报条数回答不了「占了多少上下文」)。世界设定输入框拉伸到与封面等高(`align-items: stretch` + `flex:1` + `min-height:150px`)。新增 `tools/verify-injection.mjs`:**直接读会话日志**(多帧 zstd 逐个解)统计每个会话的「占位/世界设定/世界书路径/轮次快照」,一跑就看出 9 个 DM 会话全是「只有占位文案」。测试:smoke-dm 445(+24:装配上下文取 id、裸装配不注入、常驻条进 standing 且不进快照、空壳不进 standing、空壳不注入、总览数字、冷启动补读宏表)、smoke-card 196(+22:空壳判定 9 条反例、空壳不计入世界书、类别标签、导入摘要说明)、smoke-client 新增空条目折叠/常驻体积/`worldtext` 拉伸断言。变异验证:把 `context.agent` 换回 `ctx.agent` → 7 条立刻变红;让 `renderConstantLore` 返回空串 → 2 条变红 | | **1.10.2** | 用户确认「角色卡是好的,只是第一条不对」+「不要把 PNG 的名字写到角色卡里」→ 定位到 1.10.1 的判定**太宽**:它把 `first_mes`(开场白)也算成「角色字段」,而真事故那张卡 `description/personality/mes_example/scenario` 全是 0,只有 206 字的 `first_mes`(**故事开场白**)+ 22 条世界书 + 书名,于是照样被建成了一个空壳角色。**收紧为只认 `description` / `personality`**(故事书同样有开场白与对白,这两项不是角色专属);用真卡回归验证:`hasCharacterFields(那张卡) === false`。另外按用户要求把**卡封面移到「世界设定」旁边**:新增 `session.cover = { card, file, name }`,面板那张卡改成两列(封面 116×150 在左、设定在右,封面走 `thumb=1&width=240` 降采样);角色行不再拿卡面当立绘(只有**生成出来的**立绘才挂在角色行上,仍是有图两列、图在左)。面板标题改回「角色卡」。**老数据自动迁移**:`loadSession()` 把「键不在角色表里」的 `portraits[name].card` 认成封面,用户不必重导(真事故那张就是这种形状)。测试:smoke-card 174、smoke-dm 421(+3 封面迁移:认得出、属于角色的不当封面)、smoke-client(角色卡标题 / 只有卡面时角色行不出图 / 有生成立绘才两列 / 封面在世界设定卡内且带 thumb 与 sessionId / worldwrap 两列样式);把 `first_mes` 加回判定、去掉 `data-cover`,用例立刻变红 | | **1.10.1** | 用户对 RP 面板的三条要求:① **去掉「+ 添加宏」**(面板的宏卡片改成**只改值**:名字来自卡里的占位符,改名/加宏都和卡对不上;要加宏去设置页「默认宏列表」);② **去掉面板里的「PNG 故事书导入」**(导入入口回到工作区那一行的 chip,1.8.5 修好重新判断后它已经可靠);③ **「角色卡」→「人物」,并且卡名不再无条件当人物** —— 故事书(卡里没有角色字段)的 `name` 是**书名**,早先会被建成人,于是「下班,然后成为魔法少女」这种书名出现在人物列表里。判定收在 `hasCharacterFields()`:描述/性格/对白范例/开场白(含备用)任一非空才算角色卡;`scenario`/`creator_notes` **不算**(故事书也有)。故事书仍导世界书与情境(`world` 里标「【故事书】」),只是不建人物、不登记立绘,导入结果里明确写「卡里没有角色字段 → 没建人物」。另外**有立绘的人物行改成两列布局(图在左 104×140、文字在右)**,不再把大图竖着塞在文字下面把面板拉长。测试:smoke-card 172(+16:字段判定 9 条 + 故事书/角色卡两条 import 路径)、smoke-dm 412(+8:故事书不建人物/不登记立绘/卡面仍复制/文案标注)、smoke-client(人物标题、无「+ 添加宏」、无导入卡片、`data-hasface` + `charface`→`charbody` 顺序 + 两列样式) | | **1.10.0** | **卡面按「选中再看」加载**(用户要求:列表不出缩略图,选中那张才看图)。新增 `lib/png-thumb.js` —— 纯 `node:zlib` 的 PNG 解码 + 整数倍 box 降采样 + 重编码(**无第三方依赖**):8bit 非隔行的灰度/灰度+A/RGB/RGBA/调色板都支持,16bit/Adam7/无像素数据一律返回 `null`,卡面路由 `?thumb=1&width=N` 收到 `null` 就**回退原图**(宁可慢也不发坏图)。为什么必须降采样:卡 PNG 单张可能几百 KB~数 MB(正文上百万字),原图直出会拖死两端;配合 `cache`(mtime+宽度的内存缓存 300 条)后重复请求不再重算。界面:列表**一张图都不请求**,选中后预览区出 `.face`(`thumb=1&width=420`、`loading=lazy`)。测试:smoke-card 156(新增 14 条**像素级**断言:纯色保持、左红右蓝 box 平均分界、黑白各半取中灰、灰度单通道、带 ccv3 文本块的真图、以及 16bit/隔行/截断/非 PNG/无 IDAT 全部回退)、smoke-dm 403(路由:体积变小、IHDR 宽度=请求值、源图够大时回退、解不了回退原图字节一致)、smoke-client(列表无 img、预览恰好一张、带 thumb/width/sessionId/lazy) | | **1.9.3** | 修「保存按钮看不清」:我把 `--dsw-alias-brand-primary` 当成「蓝色品牌色」用了 —— **它不是**,它是**高对比前景色**(浅色主题近黑 #0f1115 / 深色主题近白 #f9fafb),配上写死的 `color:#fff` 就成了**白底白字**。改成宿主自己的主按钮配法:底色 `--dsw-alias-button-primary-fill`、文字 `--dsw-alias-label-primary-foreground`(hover 用 `--dsw-alias-button-primary-hover`)。测试:smoke-client 新增「主按钮底色/文字 token + 不许写死 #fff」与「样式表花括号配平」断言;撤掉前景 token 即红 | | **1.9.2** | 用户对着 1.9.1 的截图报了四点,其中「图像尺寸怎么没写默认值?」暴露一条**重要环境事实**:`dsh-client-modules` 对插件 bundle 有**文件监视**(HMR),我改 `client.js` 后页面刷新就能拿到新界面,**但宿主 `lib/` 仍要重启** —— 于是出现「界面是新的、`/rp-tools/state` 里没有 `imageSizes`」的组合,尺寸行落到了 `[0,0]` 兜底。两处都修:① 客户端新增 `resolvedImageSizes()`,配置缺该档时显示**内置默认值**(永不显示 0),保存时把解析后的值一起交回去(旧宿主不补,交回去即自愈);② 视觉统一到宿主的语义 token:输入框 `bg-layer-1` + `border-l4` + 8px 圆角、聚焦 2px `brand-primary` 细环(替掉浏览器默认那圈又粗又亮的 outline)、`×` 改成 22px 幽灵图标按钮 `.iconbtn`(hover 转危险色)、「+ 添加」改成整条虚线 `.addbtn` 并与上一行拉开间距、卡片 `.5px border-l4` + 12px 圆角 + 14px 内边距、小节标题下加发丝分隔线、风格行 hover 高亮 + 参数小标签。测试:smoke-client +5(iconbtn/addbtn/A 输入框 token/聚焦环/缺配置时不显示 0 且保存交回默认值),smoke-dm 394、smoke-card 138 全绿 | | **1.9.1** | **RP 设置页重排成三大类**(设定 / 图像 / 工具),补齐一处真缺口:**3 个图像尺寸改成全局可编辑**(场景/立绘/道具各一对宽高)。以前尺寸存在每个风格里、界面只能看不能改,现在提到 `config.imageSizes`,解析顺序 = **全局优先 → 风格自己的 sizes(手写特例)→ 兜底**(全局优先是刻意的:否则用户改了全局却发现出图没变会非常困惑);老配置自动补上并落盘,保存接口只收那三个槽位、非法宽高不改旧值。**风格库紧凑化**:一行一个风格(名称 + key + 自定义标记 | CFG | 步数 | LoRA | 触发词 | 试出),**去掉「+ 新增风格」、名称与 key 不可编辑**(要加风格就改 styles.json,设置页顶部有「打开配置文件」);内置风格不可删、自定义风格可删。提示文字整体压缩成一句话,保存/恢复默认移到页头。测试:smoke-dm 394(+11:尺寸解析顺序/非法值回退/保存与忽略未知槽位)、smoke-client(三大类标题、三档尺寸共 6 个数字框、无「新增风格」、名称不是输入框、保存带 imageSizes) | | **1.9.0** | 回答「`{{char}}` 和 `其它 {{…}}` 是什么、为什么不用填」带出的一个真问题:**带参数的占位符以前是「去花括号留文字」**,于是世界书里残留 `random:1,10` 这种脏字串。现在改成:`{{random:a,b,c}}`(逗号或 `|` 分隔)与 `{{roll:2d6+1}}`(`d20` 默认一颗,支持加减值)**导入时当场算出来**,其余不合法名字**整段删除**。展开用**确定性随机**(FNV-1a + mulberry32,种子 = 卡库相对路径 + 序号):同一张卡每次导入结果一致(重导不会换个世界),换卡才会不同。预览的占位符统计旁边新增一行说明:`{{char}}/` 已展开成卡名、`` 已展开成玩家称呼、合法宏就是下面那些表单行、时间类每轮自动、`random/roll` 导入时抽一次、不合法名字已删除。测试:smoke-card 138(+10:抽取范围/确定性/分隔符/掷骰加值/整段删除/统计分类) | | **1.8.9** | **导入界面的宏改成「只填值」**(用户对着截图提的三点):① 去掉「+ 添加宏」与每行的 ✕ ——名字来自卡里的占位符,多加/删掉都会和卡对不上,想预设更多默认值就去设置页的「默认宏列表」(那里的名字可改);② 值按默认宏列表预填,来自预设的行标「预设」、自动宏标「自动」,区块顶部统一说明来源与条数;③ 布局重排:名称列**定宽 108px**(等宽字体),值列吃满剩余,右侧固定 44px 放标记,长宏名省略号,行高与输入框统一 30px,列表最多 `min(38vh,320px)` 并可滚动 —— 不再出现截图里那种忽宽忽窄、标记把输入框挤歪的样子。另外按「很多宏提供自动设置」**扩充自动宏**:`{{year}}/{{month}}/{{day}}/{{hour}}/{{minute}}/{{second}}` 现在也由宿主现算(两位补零,年份四位),仍可填值钉死;设置页新增一行提示列出全部自动宏,并说明「在这里给默认值 = 钉成固定值」。测试:smoke-dm 381(+10:自动宏判定/取值/固定值优先/名单下发)、smoke-client(宏行 data-auto、留空=自动、无增删、预设标记、设置页列出自动宏),两处都做了变异验证 | | **1.8.8** | 默认宏列表**出厂自带一条 `user -> 玩家`**(用户反馈:给个空列表不知道该怎么填)。老配置(没有 `macrosSeeded` 标记)打开时**一次性**补上这条并打标记 —— 所以用户删掉它之后不会被再塞回来;保存过就按用户自己的来。界面文案改成说人话:非空时提示「左边是宏名(对应文本里的 {{…}}),右边是默认值;会话里填过的用它自己那份」,并用一条具体例子收尾(`例:user → 玩家 表示把 {{user}} 换成「玩家」`);空列表时直接告诉用户「点 +添加,名字填 user、值填玩家」。测试:smoke-dm +3(出厂自带 / 删掉不补回 / 老配置迁移且一次性)、smoke-client +2 条空列表引导断言 | | **1.8.7** | 设置页的**「玩家称呼」升级为「默认宏列表」**(键值对编辑器:名称 + 值 + 删除 + +添加,**名字可改**)。语义:默认列表**只提供默认值**,会话宏表里有同名键就用会话的 —— 也就是原来 `{{user}}` 那套规则推广到所有宏名。存储 `config.cards.macros = { 名: 值 }`,老字段 `cards.userLabel` 自动迁移成 `{user: …}` 并保持同步;非法的键名与空值在保存时丢掉。宿主把默认列表里的名字也注册成宿主变量(世界书/角色卡写了 `{{place}}` 而会话没填过它时靠这条兜底,不然严格插值会整轮报错)。**规则刻意分两处**:设置页的名字来自「预设」(还不知道用哪张卡)所以能改;RP 面板/导入表单里列出的名字来自卡里的占位符,**不能改名**(改了匹配不上),值可改。导入表单只列**卡里真出现**的宏(+ user),值用默认列表预填。顺带修了一处一致性问题:provider 与注册表原先把会话 id 固定在挂载那一刻,现在统一取作用域的实时身份。测试:smoke-dm +20 条(过滤规则 / 合并优先级 / 老字段兼容 / 端到端「加一条默认宏 → 装配后注册成变量」/ 会话值覆盖默认值)、smoke-client 新增设置页键值编辑器(改名后按新键提交)与导入表单预填断言 | | **1.8.6** | **立绘持久化**:角色卡生成出来的立绘原本只活在面板组件的 state 里,关面板/刷新就没了(用户报的「下次打开就消失」)。现在生成成功后把 ComfyUI 的 **(file, subfolder, type)** 三要素 POST 到新的 `/rp-tools/portrait`,写进**会话配置** `portraits[角色名].generated`;面板加载时用同样的三要素拼回 `/rp-tools/media` 的 URL 装回界面。只存三要素不存 URL(origin 变了 URL 就失效);卡面(导入 PNG 卡时登记的 `card`)与生成图并存互不覆盖;「收起」会同时清掉会话配置里那份(否则重开又装回来)。`/rp-tools/preview` 因此新增 `files[]`(原始文件描述符)。测试:smoke-dm +10 条(保存/落盘/覆盖/清除/三类缺参 400)、smoke-client 新增「重开面板装回立绘」「出图写回会话配置」「收起同步清除」,撤掉装回逻辑即红 | | **1.8.5** | **用户报的「切回 dm 按钮不见了」真正原因:值都对,但切换后没有重新判断。** 三个观察合起来锁定了它 —— 刷新页面就回来(数据源没问题)、dm 真的生效(切换成功)、按钮本来就在新对话首屏。客户端那份投影在切走之后**不再更新**(官方 chip 看起来正确是因为它自己持有 staged 值),于是组件入参一个都没变、不重渲染、闸门答复也一直是旧的。修法:订阅宿主广播的 **`agent-preset/selected`**(在 API 转发白名单里,客户端 `ctx.remote.$on` 收得到,官方客户端插件同款用法)→ 清缓存 + 重算(`presetRev` 进 key)。同时修掉一个真 bug:`rootRef`/`slot` 两个 hook 原本写在**早退之后**(等于按条件调用 hook),已提到早退之前。测试桩也修了:钩子槽位原先**跨组件共用**,加 hook 后 dock 的 `open` 读到了 RP 面板的 `lib`(假失败查了很久),现在按组件类型隔离 + `resetHooks()` | | **1.8.4** | 用户报「重启后入口还是没有」→ 判据进一步收紧为**两侧不一致时一律往「显示」靠**(dm:任一来源说是 dm 即算;开局:只有两侧**都**说已开局才收起)—— 入口少显示一次用户就再也找不回来。**RP 面板里新增常驻导入入口**(「PNG 故事书导入 → 展开卡库」,与 chip 共用同一套界面与逻辑,但不判定预设/开局):会话一旦开局 chip 必然消失,而「再导一张卡」正是开工之后才有的需求,这条是保底通道。面板顶部新增**入口判据诊断行**(界面看到的预设/开局 vs 宿主答复),下次出问题一眼可定位。客户端 `RpCardImport` 拆出 `embed` 模式,面板与 chip 共用同一个面板主体 | | **1.8.3** | **入口可见性改为「宿主说了算」**:新增宿主路由 `/rp-tools/gate`(回答「这个会话现在是不是 dm」「有没有真的开局」),客户端把投影值只当快速路径,答复按「会话+blank+预设」做 key 缓存、key 一变即作废。修掉用户报的「切走 agent 预设再切回 dm,导入入口永久消失」—— 根因是切预设会重建投影基线、把基线里没有的键**删掉**,`projectionValues.agentPreset` 读成空串;顺带修掉「摘要缺席被当成已开局」导致入口闪现即消失,以及 `selectDmPreset` 拿被清空的投影误判「需要再切一次」。测试:smoke-dm 335 条(+10 条闸门断言)、smoke-client +3 个投影损坏场景,均做过变异验证 | | **1.8.2** | **会话工作区解析改为四级链**(内存 → `ctx.get('sessions')` 按 id 现查 → 会话配置里落盘的 `cwd` → 请求参数):此前只有进程内存,重启后恢复的会话拿不到工作区,卡库/世界书路径随之落空。**「拿不到工作区」不再是猜路径**:`safeCardPath` 要求根目录非空且绝对,否则 400 并说明原因(原先 `resolve('')` = 进程 cwd,报出 `stat '\同人\X.png'` 这种莫名的 ENOENT)。客户端所有读盘卡路由(列表/预览/卡面)都带 `sessionId`,并修掉 `API.card` **吞掉 workspace 参数**的写法;`ensureCwd()` 缺 cwd 时回宿主问一次。测试:smoke-dm 323 条(新增工作区解析链 4 条 + 空根目录回归)、smoke-client 新增「卡请求必须带会话身份」「cwd 缺失要回宿主问」两组,均做过变异验证 | | 1.0.0 | 只有 `rp_random`(骰子/区间/抽取/布尔) | | 1.1.0 | 新增 7 个 RP 工具;生图从「依赖 dsh-comfyui 工作流库」改为**直连 ComfyUI + 自建同源媒体代理**;风格库内置 10 种(含默认 `manga`);**会话级隔离**(世界/角色卡/随机表/前缀/风格);设置页「RP工具」(含工具清单与参数说明);DM 会话头部「🎲 RP」浮层面板;负面词改全局并预置一套;RP 工具**只在 dm 预设作用域注册**;dm 预设白名单与桥接插件 | | 1.1.1 | DM 判定收敛为**权威方案**(客户端读 `projectionValues.agentPreset`,宿主 `agent/created` 直接读 `agent.options.preset` 自动登记);修掉「空串短路兜底」导致的入口静默不出现;删掉 `rp-bridge.mjs` 里靠猜测取会话 id 的登记逻辑;**RP 面板从自绘浮层改为右侧栏页签**(`sidebar.right.pane.tab`),入口移到头部右上角;新增 `tools/smoke-dm.mjs` | | 1.1.2 | 设置页三处改进:**下拉框终于能看清**(原生 option 不继承背景,改用主题 token 上色);工具列表**只留名字与一句话说明**(参数清单删掉);**风格库可增删** + 每条风格的 **LoRA 从本地清单下拉选取**(新增 `/rp-tools/loras`,读 ComfyUI `/object_info`);内置风格不可删;冒烟测试扩到 29 条断言(含风格增删与 LoRA 清单),并把 `DSH_HOME` 指向临时目录做到**零污染** | | 1.2.0 | **提示词注入**:`rp:standing`(210) + `rp:turn`(20) 两条通道 —— 世界设定/角色卡终于真的进了模型上下文(此前 `world` 只是存在会话文件里,**从没进过上下文**) | | 1.2.1 | **角色卡 8 字段**(学 hermes:给样本 > 给形容词,含 `first_mes`/`mes_example`);**角色卡按需注入**(常驻只放索引,详细卡出场才展开);**角色一致性第一版**(每角色固定 seed,多角色同框时不固定);fork 继承会话配置 | | 1.2.2 | **世界书**(会话工作区的 `rp-worldbook.md`,关键词触发 + 常驻 + 概率 + order + 预算,`rp_lore` 按条补读);**状态追踪**(`rp_state`:场景/时间/地点/在场/线索 + 队伍 + 自由旗标,空串即清除) | | **1.3.0** | **架构收敛:一切都在 dm 作用域。** host 组合不再注册任何模型工具(`rp_random` 也从全局收进 dm,全局 `inject` 改为 `[]`);**提示词注入从全局 `apply()` 移到 agent 作用域** —— 此前它让每个会话都带上跑团世界观,还得靠「猜当前会话」的启发式(会把别的战役设定注进来),两者都已删除;dm 预设 `keepGlobalTools` 从 11 项缩到 3 项;修复 `/rp-tools/tools` 因删除全局数组而静默 400;测试补「路由体检」与「作用域隔离」断言,共 **146 条** | | **1.8.0** | 风格库**整理为 3 个**:二次元(用户的 `uncensored_anime` workflow)/ 写实(`uncensored_real`)/ 黑白漫画(`manga`),其余内置风格自动从老配置清除,默认风格 = 二次元;**卡库目录默认 = 会话工作区下的 `rp-cards`**,并彻底删掉对固定路径 `D:\Story\sillytavernassets` 的判断与私有索引那条路 | | **1.7.0** | 导入界面列出卡里**所有** `{{x}}`(含 `{{time}}`/`{{date}}` 等**自动宏**:装配时现算,不用填,也可填固定值钉死);**风格库精简为「二次元 / 写实 / 黑白漫画」**三个内置风格(其余 9 个 krea2 内置风格自动从老配置里清除,默认风格回落 `manga`) | | **1.6.0** | **宏表(按会话隔离)**:`{{user}}` 与自定义 `{{x}}` 的值存在会话里,**第一次导入时由界面问用户**(默认取全局「玩家称呼」,可改、可加),RP 面板随时可改,DM 也能用 `rp_session` 设;注入时由 `systemPrompt.variable` 逐名注册成宿主变量 —— 所以改值后已导入的文本立刻跟着变(不再把值烤进文件)。`{{char}}` 仍在导入时展开成卡名;未注册的宏中和成全角(宿主对未知变量严格)。另:旧版共享世界书**不再自动继承**(那正是新会话被污染的根因),改为面板显式「并入本会话」;fork 会继承父会话的世界书 | | **1.5.0** | **世界书改为按会话隔离**(<工作区>/rp-sessions/<会话 id>/rp-worldbook.md)—— 修掉「同工作区两个会话的世界书混到一起」的事故:原先它放在工作区根目录,而工作区是按目录共享的;老文件首次读取时一次性迁移一份(保留原文件)。另:开场白改用**引导文件**( p-cards/*.opening.md,不截断,全部开场白都在里面)+ 面板可选第几条;截断不再往正文里插「(已截断…)」;世界书条目可查看详情/编辑/新建/删除 + 常驻就地开关;属性标签中文化(name:→名称、gender: Female→性别:女) | | **1.4.0** | **PNG 故事书(角色卡)导入**:工作区那一行的「📖 导入 PNG 故事书」→ 列卡库(本地 3269 张,服务端过滤/分页)→ 预览 → **导入并开始**:世界书**追加合并**进工作区 `rp-worldbook.md`、卡全文写 `rp-sessions/<会话 id>/cards/.md`、卡面复制成 `rp-sessions/<会话 id>/cards/.png` 并当默认立绘、角色卡/世界/战役名写进会话配置、**自动切 `dm` 预设并发出开场指令**。四件套依据实测(`first_mes` 100% 广告 → 换 `alternate_greetings`;32% 条目无 keys → 补 `constant`;单卡最大 167 万字 → 限量 + 全文落文件)。新增 `lib/card-png.js`、`lib/card-import.js`、4 条路由(含路径逃逸防护)、私有卡索引缺失时的目录扫描兜底;测试补 `smoke-card.mjs`(64 条)与真卡库探针 `probe-cardlib.mjs`,共 **262 条断言** |