# 房间与设置 —— 界面设计 > 2026-10-05。**权威的样子在 [`原型/房间-原型.html`](原型/房间-原型.html) 与 > [`原型/设置-原型.html`](原型/设置-原型.html)**——那两份是能点的,改版式先改它们。 > 这份文档写的是它**为什么长这样**,以及**每一块的数据从哪来**;机制(一个包两个半边、 > 只读端点、槽的选型、围栏)在 [`2026-10-05-房间面板.md`](2026-10-05-房间面板.md)。 ## 0. 两条总则 **① 界面是附加层。** 宿主侧的工具、局面状态机、演员池不为它改结构。所以下面的每一个区块 都要能回答「数据从端点哪个字段来」——答不上来的,就是该补进端点或补进宿主侧的东西。 **② 两种形态各自完整。** | | 走哪 | 什么时候 | |---|---|---| | 对话页 | 直接在对话里玩 | 摸鱼——隐蔽,但体验差(要在聊天里翻线索、记时间线) | | 团队页 | 标签里的工作台 | 正玩——三栏摊开,线索、关系、时间线都在眼前 | **这条决定了房间页必须有消息流**:如果它没有,正玩的人还得切回对话页看别人说了什么, 那就成了"两处都不完整"。同理,**房间页不重造输入框**——DSH 那个输入框就在标签下面, 它就是对话页的那一个,两种形态共用同一局。 ## 1. 层级 ``` 会话视图(DSH) ├─ 标签栏:对话 / 轨迹 / 团队 / 上下文 ← DSH 的,不是插件的 │ └─ 「团队」标签的容器 ← 便签挂在这里(position: relative) │ ├─ 三栏 │ └─ 便签层(position: absolute) └─ 输入框(DSH 的,在标签下面) ← 说话用它,不另造 ``` **便签归标签页管,不归窗口管。** 它挂标签容器、坐标相对容器,所以切到别的标签时它跟着 这一页一起消失,切回来位置分毫不动。**但它的状态(位置 / 颜色 / 文字 / 序号)存在宿主侧、 按会话分**——不然切走再回来就丢了。 设置页在另一个地方:**插件管理 → 点开 `@max-null/dsh-jubensha` 那张卡片**(挂 `plugins.bundle.config`,key 是包名——DSH 0.2.0 把插件配置放在插件管理里,不再占设置里的 一栏导航。详见 §7)。 ## 2. 房间页 ### 2.0 怎么进来:没有界面入口,入口是对话 **新会话的空白页(Hero)上没有位置放剧本杀入口,而且不该去挤。** `ui-conversation` 在那个页面上 只留了三个槽——`conversation.hero.workspace` / `.brand.mark` / `.agentPreset`——而三个都是 `single`,抢哪个都是**替换**掉官方的东西(标题前那个方块就是 `brand.mark`)。它的 `conversation.content` factory 的 children 里也只列了这三个。所以「在空白页加一个按钮」这条路是堵的。 **真正的第一步在工具上。** 用户说一句「来一局剧本杀」,模型看到的是插件注册的那几个工具。而 原先的 `jubensha_case` 一上来就要 `dir`——**「本子放在哪儿」正是新会话里唯一没法知道的事** (还没开局,「团队」那一页是空的)。于是 2026-10-06 加了 `action="list"`: - 它列出插件自带的与用户加的那些本子,每个带 `caseId` / `title` / `genre` / `seats` / **`path`**; - 那个 `path` 就是下一步 `load` 要的东西——省得模型再猜一次; - 它的分支在 `dir` 检查**之前**,因为它是唯一不需要别的参数的一步。 工具描述也改了:开头就说「新会话从 `action="list"` 开始」,并说清为什么。 **于是进来的路是**:用户说一句 → 模型 `list` → 挑一本(或问用户挑哪本)→ `load` → `start` → `spawn` 让几位上桌。**界面上的「团队」标签是给已经开着的局看的,不是入口。** 实测:发一句 「用 jubensha_case 列出能开的剧本杀本子」,五秒里列全六本。 **还有一个隐蔽入口:连点标题三下。** 上面那条路要求用户知道该说什么,而新会话那张空白页上他 手里没有一个能点的东西。所以加一个手势(2026-10-06,用户提的):在那一页**连点标题三下**, 输入框里就出现「来一局剧本杀」——他看得见、能改、能删,然后自己按回车。 - **认标题按 class 后缀 `_headline`**:那一页的 DOM 是 `vdz1Dq_root` / `_stack` / `_headline` / `_body`,没有 `aria-label` 也没有 `data-*`。哈希前缀会随构建变,而 `_headline` 来自源码,稳。 按文本认不行——「探索未至之境」是 locale 字符串,换语言就没了。 - **是「填」不是「发」**:三下点击容易误触,而直接发走得太远。而且填的时候走 `execCommand('insertText')`,不是改 `value`——那个输入框受 React 管,直接改会被下一次渲染抹掉。 - **三下的间隔上限 900ms**:白天随手点的三下不该攒到晚上一起算。 这个手势与「发一句话」是**两条并行的路**,不是替代:知道该说什么的人直接说,不知道的人点三下。 ### 2.1 顶部:步骤条 五个阶段横排,走到哪亮到哪:自述 → 问话 → 搜证 → 发言投票 → 复盘。 走过的实心圆、当前那一个加一圈、没走到的空心;圆点之间连线,走过的那段上色。 **它只是进度,不是导航。** 早先这里写的是「它同时是切换器——点哪个阶段,中栏就换成那个 阶段的页面。进度与导航是同一件东西」——那个论点听着聪明,而实践中它是「把进度条当成了 按钮」,两个后果都由用户当场指出来: - 还没开局时五个都能点,而那时没有阶段可言(摆一条能点的横排,只会让人以为自己漏了什么); - 能直接点「复盘」——那会把整局推理跳掉。 所以**没开局整条不渲染**;**只有已经走过的能点,而那一下是「回看」不是「跳步」**;没走到的 禁用。回看时旁边多一个「回到当前」,点了重新跟着局面走。 **进度不记在客户端**:当前阶段直接取 `snapshot.game.phase`——进度是局面的一部分,而局面在 宿主那儿。客户端只多一个「用户正在回看哪一步」的本地状态。 ### 2.2 左栏:这一局的人 | 区块 | 显示什么 | 数据来源 | |---|---|---| | 这一桌 | 每个座位的头像、角色名、身份、演员;没上桌的位子显示「还没上桌」 | `game.seats` + `players` + `cases[].roles` | | 人物关系 | **缩略是列表,点开是弹窗里的连线图** | `scene.relations`(`from` / `to` / `label`) | | 时间线 | 每个人交代过的时刻(24 小时制),以及**还没交代自己那段时间的人** | `game.timeline`——由 DM 经 `jubensha_state action="timeline"` 记(**那是读正文的语义,前端不做**) | **人物关系:缩略图与原图。** 这个结构是用户给的(2026-10-06),它让两件事各自成立: - **缩略**(左栏那 258px)**用列表**——一行一个人,名字在左、关系在右。那个宽度里列表好读。 - **点开之后弹窗给足空间**,里面画**连线图**——中心在正中、四个节点在四角、四条标签写在线的 0.38 处(那一段是四条线散开最彻底的地方,所以各占一方)。标签带底色描边,跨在线上而把线 遮断。关闭三条路:按钮、点遮罩、Esc。 **为什么不是「一直画连线图」**:原型(`原型/房间-原型.html`)画的就是连线图,那在**它自己的 假设下**是对的——原型里的标签是「叔侄」「棋友三十年」这种两三个字。而真本子里会写「上个月被他 查过账」,九个字铺开 81px,而线的中点正是节点框旁边,于是必然压住两头的框。 我先试了三版想在小地方塞下那张图(正圆星形、错开的树形、把标签挪来挪去),全撞——**根因是 258px 宽放不下「一个中心 + 四个节点 + 四条带字的连线」**,无论怎么排。所以换了个维度: 不是把图缩小,而是**给它另一个尺寸的容器**。 **记这一条的落点**:`label` 是本子作者的**自由文本**,UI 不该反过来要求作者写短——那是把排版 难题推给写故事的人。空间不够时该换容器,不该削内容。 **时间线不显示真相。** 真相在 DM 手里、复盘前是封着的——能出现在这儿的只有玩家听过的话。 这不是设计上的克制,是封存机制的直接后果(`sealed()` 按会话判,与上下文无关)。 ### 2.3 中栏:桌上说了什么(2026-10-06 换过来的) **这一栏本来在最右边,现在在正中。** 用户的话是「右侧会话区域其实才是重点,我们把右侧会话放在 中间吧」——而他说得对:玩的时候一直盯着的是**谁说了什么**,而阶段页与资料是**查**的时候才看的。 换过来之后列宽也跟着调(272 / 1fr / 392),会话拿到最宽的那一栏。 **名字与气泡按头像配色。** 色相直接取 `avatarShape(seat).hue`——头像就是这么取的,所以名字、 气泡、头像三者同色,一栏扫下来不用看字就知道哪几句是同一个人说的。底色用半透明的 `hsl(H 62% 48% / α)` 而不是写死的浅色:让底色自己透出来,浅色主题与暗色主题共用一套值。α 分两档 (名字 0.18、气泡 0.055),再加一道左边的实色线。 两个组件跟着改了名:`RightColumn` → `SaidColumn`、`MidColumn` → `PhaseColumn`——它们不在原来的 位置上了,留着旧名字下次读代码会找错栏。 ### 2.4 右栏:剧本与阶段页 **顶部常驻剧本正文**,默认展开、可折叠——回看用的。下面跟着当前阶段的页面。 限宽 640:**正文的行宽有最佳值**,铺满一屏反而难读。而它现在在右边,所以实际拿到的是 392 那一栏 ——窄一点可以接受,因为这里是**查**的地方(阶段页的引导、线索卡、复盘),不是要一直读的正文。 五个阶段页各显示什么: | 阶段 | 页面内容 | 数据来源 | |---|---|---| | 自述 | 逐个上桌:每个 AI 玩家说了什么、谁还没上桌 | 对话 + `players` | | 问话 | 每个人说过的**全部**(按人折叠,summary 给条数)——问话阶段读的是「他有没有改口、有没有漏」 | 对话(`said` 投影,与右栏同源) | | 搜证 | 线索卡:已发的展开,未发的显示成背面 | `game.revealedClues` + `cases[].` 本子的 `clues`;**发放状态就是 `revealedClues`** | | 发言投票 | 每人**最近一段**发言,以及「还没出声」的人 | 对话(同上;这一页只取最后一段,判断依据够了) | | 复盘 | 真相、那一夜五段、三个「其实不是」 | `truth`(此时已解封) | **线索卡是「还剩几条」的读数**:未发的显示成背面,一眼看得出还差几条。 **「引用到对话」按钮是做的**,但**它得有自己的位置**——卡片里那个按钮,不是右键菜单。 右键那套是「添加到会话」那类插件的入口,撞上去才是冲突。 ### 2.5 这一栏原来是「右栏:桌上说了什么」 **只读的消息流**,加上一句「说话在下面那个输入框里」。它是团队页自足的那一半: 正玩的人不必切回对话页才知道别人说了什么。2026-10-06 它挪到了正中(见 §2.3), 这一段留着是为了记住它原本的角色——**这一栏从第一天就是「自足」的那一半**, 而它当时被放在最右边,与「不是重点」的暗示一起。 ## 3. 设置页 **只放跨局的东西**。判断是:**换一局它还成立吗**——演员是,座位不是。 | 区块 | 内容 | 数据来源 | |---|---|---| | 演员 | 名字、性格、头像、**跨局印象(只读)** | `actors`(读)+ **写端点** | | 本子来源 | 插件自带(只读,显示有几本)+ 自定义目录(可加可移) | `get /jubensha/case-dirs` 与 `post …/add`、`…/remove` | **跨局印象是只读的**:那是每局复盘后自己攒起来的,让人手改等于伪造他的记忆。 **性格在这里手写**,LLM 起草仍留在对话里(`jubensha_actor action="draft"`)——设置页是 浏览器半边,它调不了模型。 ## 4. 便签 右键页面任意位置留一张;九色(**白底排第一格,最隐蔽**);**自增序号** `#1 #2 …`; 点开就能改;`✕` 删掉。序号是给「引用」用的——游戏里说「便签 3 那条」比复述一遍省事; 删掉的号不回收。 **便签不进对话、不进模型上下文**:它是给自己看的备忘,不是发言。 **存放处不该是浏览器。** 便签要跟着人走(换台机器还在),而状态按会话分——落点该是宿主侧 存储,与演员池同一层。原型里落 `localStorage` 只是为了能验交互。 **它比剧本杀更通用**:右键在任意界面留便签,对所有 DSH 界面都有用。所以它将来该**独立成 插件**,剧本杀只是第一个用户;在抽出去之前,先做在房间页里。 ## 5. 实现要动的 按依赖排,前两项咬得最紧、建议一起做: 1. **端点的写路径**:演员池(设置页要写)、便签(位置/颜色/文字/序号,按会话存)。 仍是同一套 loopback + 同源围栏,服务端只认自己那几个字段。 2. **客户端重做**:三栏 + 五阶段 + 便签层。标签的 `inject` 给 `sessionId`,快照按它取。 3. **设置页**:挂 `plugins.bundle.config`(key 是包名),渲染在插件管理里那个包的详情页上——见 §7。 4. **补数据**(端点侧,按需要):线索发放状态(已有 `revealedClues`,把它按条给出来)、 关系图的形状与标签、按座位归集的「他说的时段」、本子来源目录。 ### 5.1 读会话内容走哪条路(已查清,2026-10-05) 时间线与右栏消息流卡在同一件事上:**浏览器半边怎么读会话内容**。路查清了: **`ctx.uiConversation.events.register(definition)`** —— 注册一个 `ConversationNodeDefinition`, 配一个 **session 作用域**的座位。现成的座位是 `conversation.chat.turnTail`(`scope: 'session'`, owner 自带 `turn` 数据表,`turn.data.get(key)` 直接读投影值)。 这是**唯一**的官方通道。另外两条都走不通,而原因值得记: | 试过的 | 为什么不行 | |---|---| | `conversation.view` 的 owner props | 里面只有 `inspectCall` / `viewRequest` / `openView`——**没有消息** | | `shell.overlay` 那一层 | 它是**帧级**的、`inject` 不传 sessionId。用它就得先自己回答「用户在看哪个会话」,而选中会话是 ui-workspace 的状态——**等于把视图层的知识搬进插件** | 所以这两块不是"做不了",是"要注册一个事件消费者"。下一轮先读一个现成的 `ConversationNodeDefinition` 的形状(`ui-chat` 里那些 `conversation-nodes/*` 是最近的范本), 再动手。 ### 5.2 完整链路(四处 API,一环套一环) `ui-plan` 是个现成的范本——它做的是「读这一轮提交的计划」,与「读这一轮谁说了什么」是同一件事。 **① 注册定义** `ctx.uiConversation.events.register(definition)`: ```ts const definition: ConversationNodeDefinition = { kind: 'jubensha-said', // ← 这个就是读它时用的 key target: 'chat', match: event => event.type === 'agent/inbox/spliced' ? { id: String(event.seq), role: 'start' } : null, // inserted[] 里就是消息,见 §5.3 start: (context, match) => ..., // 折一个 state 出来 update: context => context.state, buildViewNode: (context) => ..., // visibility: 'hidden',见 ② } ``` `target: 'chat'` 是必需的(`ui-plan` 有它,`inbox` 那条也有对应的座位)。而 `publication: () => 'none'` 是**另一种**写法(`ui-chat` 的 `inbox` 用),它只投影、连 `data` 都不给读——所以房间页 该照 `ui-plan` 那条。 `publication: () => 'none'` 是**只投影、不显示**(`ui-chat` 的 `inbox` 定义就是这么写的)。 那正是房间页要的:它有自己的渲染,不必在对话流里再插一行。 **② 投影的 key 就是 `kind`**。`ui-plan` 的 definition 里写着 `kind: 'submitted-plan'`,而读它时 用的是 `turnDataSource(turn, 'submitted-plan')`——**同一个名字**。所以没有单独的"注册一个 key" 这一步:**`kind` 就是 key**。 **"只投影不显示"的正解是 `visibility: 'hidden'`**(`ui-plan` 用的就是它): ```ts buildViewNode: (context) => ({ key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat', anchorSeq: start.event.seq, location: start.location, visibility: 'hidden', // 投影进得来,对话流里不出现 data, }), ``` `ui-chat` 的 `inbox` 定义用的是另一种写法(`publication: () => 'none'`,且没有 `buildViewNode`)。 两者都"不显示",而 `visibility: 'hidden'` 那条**同时给了 `data`**——读得到。所以照 `ui-plan` 写。 **③ 挂座位** `conversation.chat.turnTail`(`scope: 'session'`,owner 是 `TurnTailOwnerProps`): ```ts ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', id, locale: NS, inject: (sessionId: SessionId) => { const binding = ctx.sessions.binding(sessionId) const chat = ctx.uiConversation.binding(binding).target('chat') return { keyedHooks: { mine: (turn) => chat.getSnapshot().nodes.turnDataSource(Number(turn), KEY) }, } }, }, SomeView)) ``` **④ 组件里按轮次读**:`keyedHooks` 那个名字会被渲染器绑成 `use`(这里是 `useMine(turn)`)。 **为什么绕这一大圈**:房间页要显示"这一轮谁说了什么",而那句话是**宿主 append 的会话事件**。 浏览器半边没有别的口子能读到它——`shell.overlay` 是帧级的、`conversation.view` 的 owner props 里没有消息。所以唯一的办法是**注册一个事件消费者,把事件折成投影,再从座位里读投影**。 ### 5.3 匹配什么(已定:`agent/inbox/spliced`) 它的 `data.inserted[]` **就是一批完整消息**——每条带 `content`、`source`、`role`、`id`。所以不必 去拼 `user/message`,一个事件就把"这一轮谁说了什么"给全了: ```json { "type": "agent/inbox/spliced", "seq": 3, "data": { "target": "next-turn", "start": 0, "inserted": [ { "content": [{ "type": "text", "text": "只回复这一句:探针测试收到。" }], "source": { "kind": "user", "rpcId": "…", "clientTimeZone": "Asia/Shanghai" }, "role": "user", "id": "24a64b75-…" } ] } } ``` 而 `source.kind` 正好是**分辨"谁说的"**那个字段。实测一个会话里 `user/message` 的来源分布是: | source.kind | 是谁 | |---|---| | `user` | 真人说的一句话 | | `runtime-context` / `skill-catalog` / `time-context` | 上下文注入 | | `plugin:<名字>` | 别的插件塞进去的 | **所以"真人说的"与"插件塞进去的"分得开**,不必靠正则去猜正文像不像人话。 **还有一处要实测**:AI 玩家说的话在 DM 的会话里 `source.kind` 是什么。玩家是 subagent,它说的话 回到父会话那条路的形状要真跑一局才看得到(`jubensha_player` 的 spawn 流程)。**没看到之前不写死。** ### 5.4 一个踩到的坑:声明合并是两面的 `ctx.sessions` 在**客户端**是 `ISessions`(`api-session-controller/client` 的声明合并),在**宿主** 是 `SessionStore`(`core/session` 的类)。而本插件的 `tsconfig.json` 是**单体面**——宿主半边的 `src/index.ts` 与浏览器半边的 `src/client/*` 在同一份配置里,于是两处声明**一起生效**: `ctx.sessions` 被解析成两者的交叉,而 `SessionStore` 上没有 `binding`,`ctx.sessions.binding(...)` 就编译不过。 DSH 自己的客户端包不这样:它们用 `tsconfig.client.json` 那个叶子配置,类型面里只有客户端声明。 本插件是「一个包两半」的普通形态(不是 DSH 那种 host/client 双叶子),所以没拆面。 **结论**:在客户端半边**不要走 `ctx.sessions`**,拿会话绑定那条路要在客户端自己的服务上找。 这一条是实测出来的——报错说的是 `SessionStore` 没有 `binding`,而 `ui-plan` 里同一行代码是好的, 差别就在类型面。 ### 5.5 右栏读出来了(L2 过了) 第一版走的是 `navigation.items()` → `turnDataSource(turn, kind)`,而右栏一直是空的。换成 **`nodes.values()`** 就好了——它给**全部已投影的 node**,一步到位。 **为什么第一版不行**:`turnDataSource` 要一个 turn,而 turn 只能从 `navigation.items()` 来;而 那个索引给的是「**loaded** Turns that have a visible anchor」。**「团队」标签显示的时候对话页并 没有在显示**——那些轮可能根本不在索引里。 **还纠正了我上一轮的一个错判**:我曾写下「`values()` 拿不到投影值,因为 `data` 只在 `ChatNode` 上」。那是错的——`ConversationViewNode`(`values()` 的元素类型)**本来就有 `data: unknown`**, `ChatNode` 只是把它按 kind 收窄。类型是 `unknown`,所以读取时要窄化一次,但它就在那儿。 **教训**:一个东西有两个读取口时,「哪个能给数据」要看类型定义,不要看名字像不像。 ### 5.6 两个探针读同一份数据而结果差 100 倍时,先怀疑探针 `probe-inbox-shape.mjs` 抄了 `jubensha-seal-probe.mjs` 的 zstd 帧扫描而**抄漏了一行**—— `if (checksum) { … offset += 4 }`。同一个 180 KB 的会话文件,`seal-probe` 读出 **104 个事件**, 我的探针只读出 **1 条**。 它差点让我得出「这个会话没有 splice」的结论,而那是错的(实际有 8 条)。补上那一行之后两边一致。 ## 6. 故意不做的 | 没做 | 为什么 | |---|---| | 房间页再放一个输入框 | DSH 那个就在标签下面,而且就是对话页那一个。另造一个会跟「添加到会话」那类插件抢同一个位置 | | 投票下拉框 | 投票本来是**要说出口**的东西——说了别人才接得上话 | | 真相时间线(做在玩家的时间线里) | 它是封着的。封存按会话判、与上下文无关,这不是取舍 | | 时间线里的「他说的**时段**」 | **放错了层**,不是做不到——见下 | **「他说的时段」为什么移到这一栏。** 这条原来是 §5 的四条待办之一(「按座位归集的『他说的 时段』」)。2026-10-06 查清了它的两半: - **「谁说的」那半做得成**:真人 `user`、玩家 `agent-message`(直接在座)与 `team-message` (经 Team 的 `send_message`),而**座位能从信封前缀取**(`from p2-muvlq1zmeqy:`——那个单调后缀 是本插件加的)。右栏现在就在读这些。 - **「时段」那半不该在这里做**:它要的是「他交代过自己几点在哪」,而不是「他说了几句」。那是 **读正文的语义**——前端没有模型,而它也不该去猜。真要的话路在 DM 那一侧:它可以读对话, 再加一个工具让它把听出来的时段记下来。 所以这一条不是没做,是**放错了层**。记在这里,免得下次有人看到原型里的「时间线 · 你们说过的」 又去前端想一遍。 **2026-10-06 补:那半条路补上了。** 上面说「真要的话路在 DM 那一侧」——而写出这句之后它就停 在那儿了,那一栏一直空着(用户当天的反馈是「时间线未发挥作用」)。现在补上了:`state.ts` 多了 `TimelineEntry` 与 `recordTimeline()`,`jubensha_state` 多了 `action="timeline"`,DM 把听出来的 行踪记进局面,左栏那一栏按时间排出来,**下面一行列出还没交代自己那段时间的人**——那才是这一栏 真正有用的地方。 要 DM 记的那一条写进了开局指令,还带上两条约束:**照他说的记,别替他补**;**时刻一律 24 小时 制**(中文钟点排不了序,而排序是这一栏的全部意义)。 **判断是一条**:**凡是 DSH 已有的东西,不在这里再造一遍**;而**它没有、又不与别人抢位置的**, 该做就做。 ## 7. 设置页挂哪儿(2026-10-06 定案) **挂 `plugins.bundle.config`,key 是包名。** DSH 0.2.0 把插件配置放在**插件管理**里(侧栏「插件」那一页):一张卡片点开是那个包的详情页, 而配置渲染在描述与它声明的 rows 之间。`ui-plugin-manager` 的 `slot-contract.ts` 给别的插件留了 六个槽,其中三个是配置位: | 槽 | 认领方式 | 用在哪 | |---|---|---| | `plugins.item` | list | **官方专区(OCCUPIED)**——第三方不该占 | | **`plugins.bundle.config`** | keyed,key 是**包名** | 一个 bundle 自己的配置,渲染在它的详情页上 | | `plugins.row.config` | keyed,key 是 `包名#行id` | 一个行的配置,从那个行打开 | 本插件的 `package.json` 有自己的 `dsh.bundle.patch`,所以它**本身就是一个 bundle**,走中间那条。 **踩过的两步弯路**(两次都是「查得不够就下结论」,值得记住): 1. **先挂了 `settings.plugins.tab`**——那是「设置 → 插件」那一栏的 tab,**另一个界面**。它不算错, 只是不在用户会去看的地方(用户的原话:*设置页在插件管理中*)。 2. **然后断言「思灵里没有 DSH 的设置面板」**——凭据是 profile 的 `node_modules` 里没有 `ui-settings-*`。**那是查错了地方**:思灵的内核与它的 client 插件集**随包进 asar** (`resources/app.asar` 的 `dsh/node_modules/@deepseek-ai/`),那里面有 6 个设置包。真相只是 「我没找到入口」——**而「我没找到」与「不存在」在结论上差得很远。** 正确的参照物是**我们自己的 `dsh-node-appearance`**:它早就挂对了,注释里连「`plugins.item` 是 官方专区」「key 用包名」都写着。**同类问题的第一个动作应该是:看我们已经做对的那个插件怎么写的。** ## 8. 还没验到的 只剩一处:**便签的「真右键弹色板」**。上一轮验留便签时那一下被插件更新浮层接走了(试了 12 个点 全在 `pc-overlay` 上),而手动派发能弹出色板——所以监听那条路是通的,缺的只是「浮层不在时真人 点一下」这一次。 至于设置页:**2026-10-06 验到了**(见 §7)。挂 `plugins.bundle.config` 之后,在插件管理里点开 `@max-null/dsh-jubensha` 那张卡片,演员池(含跨局印象)、本子上哪儿找、加目录都在那一页上—— 而且上一轮 L2 加进配置的那个 `extra-cases` 目录也显示在里面。截图见 [`../docs/shots/settings-page.png`](../docs/shots/settings-page.png)。