# 房间面板 —— 界面的那条边界线在哪 > 2026-10-05。对应目标第 ① 条与设计方案 §5 拍板记录第 4 条(**两种形态并存**)。 > > **这份讲机制**(一个包两个半边、只读端点、槽的选型、围栏)。**界面长什么样、每块的数据从 > 哪来**在 [`2026-10-05-房间与设置-界面设计.md`](2026-10-05-房间与设置-界面设计.md), > 能点的样子在 [`原型/`](原型/)。 ## 0. 一句话 **面板是附加层。** 宿主侧的工具、局面状态机、演员池一行都没有为它改过——拆掉浏览器半边, 对话形态照常能玩一局。 ## 1. 一个包两个半边 `@deepseek-ai/dsh-client-*` 那一套是宿主+浏览器的双面插件形态,本插件照它搭: | | 产物 | 形态 | 干什么 | |---|---|---|---| | 宿主半边 | `lib/index.mjs` | ESM / node | 四个工具、演员池、局面状态机、**只读的房间端点** | | 浏览器半边 | `lib/client.js` | CJS / browser | 两个槽的注册与那个面板 | 浏览器半边为什么是 CJS 加一层壳:DSH 的 web 壳用一个冻结的模块表加载客户端插件,产物要 自报 id 并交出工厂(`window.__ModuleLoader__.load({ id, factory })`)。**这不是自由选择**, 壳按这个协议取产物。打包换成 `tsdown`,两个 entry 各出各的(见 `tsdown.config.ts`)。 **纯度门**:浏览器半边不许有任何跨插件的**值**导入(类型导入被擦除,不受影响)。这不是洁癖—— 跨插件值导入会让两个包的运行时状态纠缠在一起,而它们本该通过 cordis 服务或 slot 协作。 ## 2. 数据从哪来:插件自己的一个只读端点 两半是**两个进程里的两个 cordis 上下文**,不共享服务。所以「面板显示局面」需要一条跨半边通道。 **走 HTTP 而不是会话事件**:后者也能通,但要注册 conversation node 去把事件折成节点, 对「读一份当前状态」这个用途重得多。而插件自己的端点是官方给的路(`dsh-memory` 用同一套)。 - `GET /jubensha/room?session=` → `{ game, players, actors, cases }` - **必须带 `?session=`**:局面是**每个会话各自一局**(`index.ts` 的 `games`),没有「全局局面」 这回事。缺了它回 400——那比「随便给一局」诚实。标签那边的 id 来自 `conversation.view` 的 `inject`,那一头本来就是按会话注册的;工具那边的 id 来自 `exec.agent.session.header.id` - **只读**:开一局、推进阶段、发线索都在对话里说 - **围栏**:loopback 或 trustedHosts、拒 `cross-site`、`Origin` 必须与 `Host` 同源 (照抄 `dsh-memory/src/routes.ts` 的同一套,连同它防的东西一起抄) - 子路径 404、非 GET 405 **围栏的 11 条用例里绝大多数钉的是拒绝那一侧**:只测放行的话,「永远放行」也能全绿—— 而那正是最坏的结果,一个能被任意网页读到的端点会把整局局面连演员池里那些跨局印象一起摊出去。 ### 它怎么跟着对话走:低频轮询 `RoomBody` 每 4 秒拉一次那份快照,页面藏起来时不拉。 **这里改过一次判断。** 最初不做轮询,理由是「用户看着面板的时候正是他不太可能在推进阶段的 时刻」——而用户拿两张**同一时刻**的截图把它推翻了:标签里写着「还没开局」,而对话区那一局 已经打到第二轮、桌上坐着四个人。根因不是"没轮询",是"**只在挂载时拉过一次**";而标签是 **常驻**的、切走也不卸载,所以它再也不拉。 **错在判断的依据上**:那个理由是从"用户会看它多久"推出来的,而该问的是"**这个界面活多久**"。 浮层是一次性的(开着→关掉→再开,就是新的一次挂载、新的一次拉取),标签是长期驻留的—— 同一个"要不要轮询"的问题,宿主界面的寿命才是那个决定项。 ### 两处**故意不做**的 | 没做 | 为什么 | |---|---| | 面板里再放一个输入框 | **DSH 那个输入框就在标签下面**,而且它就是对话页那一个——两种形态共用同一局。另造一个不但重复,还会跟「添加到会话」那类插件抢同一个位置(本机就装着一个) | | 投票下拉框 | 投票本来就是**要说出口**的东西——说了别人才接得上话。一个下拉框只会把人从对话里拽出来 | **「引用到对话」是做的**,但它得**有自己的位置**——线索卡里那个按钮,而不是右键菜单。 右键那套是「添加到会话」那类插件的入口,撞上去才是冲突;卡片里一个按钮谁都不碰, 而且让人少想一步(复制粘贴当然也能用)。 **判断是一条**:**凡是 DSH 已有的东西,不在这里再造一遍**;而**它没有、又不与别人抢位置的**, 该做就做。这一栏是「看」的地方,也是「从这里拿走点什么去说」的地方。 ## 3. 挂在哪儿,以及那个 `one handle, one scope` 的坑 | 挂什么 | 槽 | 说明 | |---|---|---| | 标签「团队」 | `conversation.view` | 唯一的挂载点,常驻 | **标签叫「团队」,这是刻意的。** 它得与「对话 / 轨迹 / 上下文」并列而不显眼——因为对话形态 唯一的面板给不了的东西就是「看起来在干别的」,那正是这个插件存在的第一个理由(用户原话 「极其隐蔽,适合摸鱼」)。标签文字由注册时的 `label` 给,所以叫什么完全由插件定; `ui-trajectory` 是现成模板: ```ts ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, locale: NS, label: () => t('view.trajectory'), }, TrajectoryView)) ``` **曾经还有两处挂载,都去掉了**:侧栏底部一个入口按钮、一个 `shell.overlay` 浮层。它们要一份 store 来共享「浮层开着没有」,而标签不需要那件事——**一个常驻的标签本身就是「一直开着」**, 再给同一份内容配一条开合的路,只是让同一块东西有两个入口、两套状态、两处要维护。去掉之后 客户端那份 store 也删了(它存在的唯一理由就是那两处),`ui-layout` / `ui-sidebar` 两个依赖 随之去掉。 **一个 store 句柄只能挂一个 scope。** 这是去掉那两处之前踩的坑,留着备查:最初入口挂在 `conversation.input.dock`(session)而面板挂在 `shell.overlay`(root),装载时抛: 挂在 `shell.overlay`(root),装载时抛: ``` Error: store handle mounted under "conversation.input.dock" (scope "session") is already mounted under scope "root" — one handle, one scope ``` **症状很有欺骗性**:浏览器半边**加载成功了**(模块表里有它),只是注册抛错,于是页面上什么 都没有——看起来像"半边没加载",实际是"加载了但挂不上"。所以入口换成了同为 root 的 `sidebar.footer.action`。 ## 4. 排座:生成文本,而不是发号施令 面板能选本子、给每个位子挑演员,然后**生成一段可以直接粘进对话的开局指令**。 **为什么不做成"面板直接开局"**:那样它就从「另一块屏」变成了「第二条控制通道」。而设计方案 第 4 条把界面定为附加层,宿主侧的状态机与工具不得依赖客户端存在——**那条约束是靠"面板只产出 文本"守住的,不是靠自觉**。顺带解决一件小事:用户不必去记 `jubensha_player` 的参数。 生成本身是个零依赖纯函数(`instruction.ts`),判据偏内容: - 本子**路径**要写全(主持人靠它去取本子) - 座位要列全,真人位不进上桌名单 - 每个 AI 位子**指名到演员**(不指名的话跨局记忆白做) - **它不越界**:不写开场白、不写节奏、不写「你该先问什么」——那一局怎么带是主持人的事 **一个演员同一局只能坐一个位子**:别的位子已经选了他,下拉里就禁掉。同一个人演三个角色在 物理上不可能,而那是排完座就能看出来的事,不该等指令发出去才发现。 ## 5. 头像:生成是默认,不是兜底 没配图时按演员 id 画一个内联 SVG 几何图形,**同一个 id 永远同一张脸**——那样头像才是这个人 在桌上的记号,而不是装饰。生成器(`avatar.ts`)零依赖,所以两个半边引得起(宿主侧存"换没换图", 浏览器侧画它)。 **不引外部资源、不用 emoji**:渲染出来就是几个几何块,没有网络请求,也不会被误认成 emoji 标记。 ## 6. 实测 隔离实例 + CDP,`probe-room.mjs`。 | 判据 | 结果 | |---|---| | 浏览器半边加载 | 页面上出现 `label="打开剧本杀房间" text="房间"` 的按钮,console 无 jubensha 异常 | | 面板渲染 | 三本本子带座位与角色名、演员池带**两个内联 svg**(头像真的画出来了) | | 开合 | 点「关上」之后 DOM 里确实没了 | | 排座 | 选本子成功,两个演员分到两个位子且**互不相同**,第三个位子因为没人可挑而不写进指令 | | 端点 | `GET` 200、子路径 404、`POST` 405 | **探针本身修过两处**,两处都是"探针在做真人做不到的事":它先读到页面别处的 `
`(对话里
渲染的围栏就是 `
`),又用原型 setter 直接设 `select.value` 绕过了 `disabled`。
**探针报出来的"bug"如果在真人手里复现不了,那是探针的 bug**——这条值得单记,因为它两次都把
探针的问题伪装成了产品的问题。