@linxin666/dsh-remote-web-ui 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +22 -7
  3. package/README.zh.md +21 -6
  4. package/lib/client.js +75 -138
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +162 -22
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.js +2 -2
  10. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/RemoteSettingsCard.js +2 -1
  12. package/lib/types/client/index.js +3 -3
  13. package/lib/types/client/locales.d.ts +0 -4
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +5 -9
  16. package/lib/types/client/mobile-adapt.d.ts +2 -2
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +51 -131
  19. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  20. package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
  21. package/lib/types/client/plugin-card-seat.js +2 -0
  22. package/lib/types/client/settings-form.d.ts +16 -0
  23. package/lib/types/client/settings-form.d.ts.map +1 -1
  24. package/lib/types/client/settings-form.js +27 -1
  25. package/lib/types/detached-work.d.ts +45 -0
  26. package/lib/types/detached-work.d.ts.map +1 -0
  27. package/lib/types/detached-work.js +55 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +31 -10
  30. package/lib/types/lan-bind.d.ts +21 -4
  31. package/lib/types/lan-bind.d.ts.map +1 -1
  32. package/lib/types/lan-bind.js +103 -17
  33. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  34. package/lib/types/remote-channel-boot.js +4 -1
  35. package/package.json +19 -19
  36. package/src/client/PluginSettingsCard.tsx +9 -0
  37. package/src/client/RemoteSettingsCard.tsx +2 -0
  38. package/src/client/index.ts +3 -3
  39. package/src/client/locales.ts +5 -9
  40. package/src/client/mobile-adapt.ts +53 -136
  41. package/src/client/plugin-card-seat.ts +7 -0
  42. package/src/client/settings-card.module.css +31 -5
  43. package/src/client/settings-form.ts +27 -1
  44. package/src/detached-work.ts +58 -0
  45. package/src/index.ts +31 -10
  46. package/src/lan-bind.ts +111 -17
  47. package/src/remote-channel-boot.ts +4 -1
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 826dec8a7f2a90ecf59546d8ddc506508e70cb58
6
- README.zh.md: 7ba22e3137725fe96ed6899946e1b41dc609236a
5
+ README.md: 144ec159702cc3db4ce8f4c288b7273e47a72e56
6
+ README.zh.md: a1970c40c80e079ebb110e7dc6b658eb24345250
package/README.md CHANGED
@@ -1,9 +1,23 @@
1
- # DSH Remote Web UI
1
+ # dsh-remote-web-ui · Mobile & Cross-Device Remote Web Control for DeepSeek Harness (DSH)
2
2
 
3
3
  English | [中文](README.zh.md)
4
- > Remote access for the dsh web GUI that shares **one interface**: pair a phone or another computer from a QR beside the settings button, and both run the same official Web GUI this machine runs — phones get an injected portrait-touch adaptation, PCs get the full desktop — through time-limited pairing tokens and revocable device sessions. A settings toggle binds the server to the LAN, an optional Cloudflare quick tunnel reaches the internet — fronted by a never-changing stable hostname so the phone's bookmark and pairing survive restarts with zero setup.
5
4
 
6
- This repository is an external plugin package for DeepSeek Harness (DSH). It is a single dual-face package: the host half owns pairing tokens, device sessions, the `/api/pair` route family, the gated `/remote` channel, the LAN bind toggle; the browser half renders the sidebar-foot remote-access entry beside the settings button, the pairing panel with a QR code, live device status, the authorized-device roster, the settings card, and the portrait-touch adaptation layer over the official UI.
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/@linxin666/dsh-client-ui-remote-web-ui?style=flat-square" alt="Version">
7
+ &nbsp;
8
+ <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.0--rc.2-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
9
+ &nbsp;
10
+ <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
11
+ </p>
12
+
13
+ <p align="center">
14
+ <strong>Mobile & Cross-Device Remote Web Control for DeepSeek Harness (DSH)</strong><br>
15
+ <em>Instant QR Pairing · Cross-Device PC Access · Mobile Touch Gestures · Cloudflare Tunnels · Secure Device Session</em>
16
+ </p>
17
+
18
+ > Seamless remote access for DeepSeek Harness (DSH) Web GUI and desktop client sharing the **exact same interface**: pair an iPhone, Android smartphone, tablet, or another computer from the QR code next to settings. Both ends run the authentic official Web GUI — phones receive an injected portrait touch layer while remote computers enjoy the complete desktop workbench. Access is guarded by expiring pairing tokens and revocable sessions. Built-in LAN binding and optional Cloudflare quick tunnels enable effortless external access with persistent hostnames.
19
+
20
+ This package is an official external plugin for DeepSeek Harness (DSH), structured as a cordis bundle: the host half manages pairing tokens, device sessions, `/api/pair` routes, the gated `/remote` channel, and LAN binding; the browser half renders the remote access trigger, QR pairing modal, live device roster, settings card, and mobile portrait touch adaptation.
7
21
 
8
22
  ## What it does
9
23
 
@@ -58,7 +72,7 @@ Restart the profile (`dsh web`), then open the phone icon in the sidebar foot. T
58
72
 
59
73
  ## Use
60
74
 
61
- 1. Open the settings card (设置 → Web 插件 → 远程访问设置) and flip **局域网访问** on if the server binds loopback; the card shows the live bind, the firewall state, and the reachable LAN URLs. The bind change takes effect from the next `dsh web` start.
75
+ 1. Open the settings card (设置 → Web 插件 → 远程访问) and flip **局域网访问** on if the server binds loopback; the card shows the live bind, the firewall state, and the reachable LAN URLs. The bind change takes effect from the next `dsh web` start.
62
76
  2. Start `dsh web`, click the phone icon, and the panel mints a fresh time-limited QR.
63
77
  3. Scan with the phone (or open the copied link): the device pairs and boots the **official Web GUI** served cookieless by the plugin (`/pair-accept` → `/pair-app`), which reloads into `/`. On a phone, the portrait adaptation layer is already active — same layout as the desktop, same live state. Later reopens from history or a bookmark go straight back into the app (https origins; see Security model).
64
78
  4. **To pair a PC instead**: copy the same link and open it in a browser on the other computer. After the same round trip the full Web GUI runs there over the gated `/remote` channel; unpaired PCs see the guided blocking page and no data. One active token pairs one device; mint a fresh QR for the next device.
@@ -122,7 +136,7 @@ pnpm --filter @linxin666/dsh-remote-web-ui test
122
136
  pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
123
137
  ```
124
138
 
125
- The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package used here is declared in devDependencies (0.2.0-rc.1 cohort), and TypeScript/Vitest resolve types straight from node_modules — no DSH source checkout is required. The consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without type checking, so git installs work without any harness checkout either.
139
+ The peer APIs come from the official NPM SDK: every `@deepseek-ai/*` package used here is declared in devDependencies (0.2.0-rc.2 cohort), and TypeScript/Vitest resolve types straight from node_modules — no DSH source checkout is required. The consumer-side `prepare` build (`tsdown.prepare.config.ts`) transpiles without type checking, so git installs work without any harness checkout either.
126
140
 
127
141
  ## Checks
128
142
 
@@ -134,7 +148,7 @@ pnpm run build
134
148
 
135
149
  ## Harness contract dependencies
136
150
 
137
- Pinned to the 0.2.0-rc.1 line; the seams this build relies on:
151
+ Pinned to the 0.2.0-rc.2 line; the seams this build relies on:
138
152
 
139
153
  - **`sidebar.footer.action` foot seat** (the 0.1.7 shell composition): the sidebar declares and renders the seat the remote entry occupies.
140
154
  - **`ctx.layout.toggleSidebar()`** (packages/client/ui-layout): the whale button expands the collapsed sidebar through the official panel-action face.
@@ -161,7 +175,7 @@ The public path is the same round trip through a tunnel (see "Remote access over
161
175
 
162
176
  - **Pairing is the access control for the `/remote` channel, unconditionally**: every request must carry a live paired-device cookie, enforced before any bytes are forwarded. A missing or revoked session receives HTTP 403 with a JSON rejection carrying `error.code: "unpaired"`; the browser's `EventSource` API exposes only the stream failure, not that response body. This gate does **not** follow `requirePairingForLan`: the channel re-issues every call with the process's own browser-auth credential, so admitting an unpaired caller would hand out that authority. The LAN policy governs the plain `/api` surface only and can never widen the channel (issue #1665).
163
177
  - **The channel carries the process's own inner credential.** The harness browser-auth cookie is authority-bound (minted for the exact `host:port` a browser visited) and has no loopback exemption, so a proxied re-issue to `127.0.0.1` cannot reuse a device's cookie. The plugin therefore redeems its own launch token once — the same exchange a first browser visit performs — and attaches that cookie to re-issued requests. The credential is only ever exercised behind the pairing gate above; 停止/取消配对 immediately stop exercising it.
164
- - **Runtime reality: pairing does not gate direct `/api`.** Nothing in the official runtime emits the `api/gate` seam (verified against the 0.2.0-rc.1 packages installed by this repository), so a direct `/api` call from a LAN origin is governed solely by the harness fence (which auto-trusts LAN literals under a `0.0.0.0` bind) plus the harness browser-auth cookie. A browser credential a device has already redeemed therefore survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the `/remote` channel and the pairing cookie, not that credential. The plugin probes the `/api` posture and warns loudly; treat a LAN-exposed bind as a deliberate decision, and prefer loopback plus tunnels when the machine is shared.
178
+ - **Runtime reality: pairing does not gate direct `/api`.** Nothing in the official runtime emits the `api/gate` seam (verified against the 0.2.0-rc.2 packages installed by this repository), so a direct `/api` call from a LAN origin is governed solely by the harness fence (which auto-trusts LAN literals under a `0.0.0.0` bind) plus the harness browser-auth cookie. A browser credential a device has already redeemed therefore survives 停止/取消配对 until its natural expiry (30 days) — revocation binds the `/remote` channel and the pairing cookie, not that credential. The plugin probes the `/api` posture and warns loudly; treat a LAN-exposed bind as a deliberate decision, and prefer loopback plus tunnels when the machine is shared.
165
179
  - **A paired device is a full-control credential.** With host mode active it reaches the complete host API — chat, sessions, settings, credentials, agent presets, deliverables — mirroring the SDK's own stance that a loopback desktop is trusted. Only the three control planes (pairing, self-update, plugin install/remove) stay physically local. Only pair devices you control; 停止 or per-device 取消配对 revokes immediately.
166
180
  - **Control endpoints stay loopback-only**: mint/stop/revoke, the device roster, and the lan-bind status answer only to loopback, and the gated channel keeps the self-update and plugin-install paths physically local. A LAN-origin browser sees the "配对面板仅限本机使用" banner.
167
181
  - **Background file uploads ride the channel too.** The official upload service prefers a Web Worker carrier whose own globals no main-thread patch reaches, so the boot patch (and the browser patch as a fallback) publishes the official pre-Cordis hook `__DSH_FILE_UPLOAD__` and hands it the patched `fetch`: the raw `/api/session/uploadFileBinary` POST is rewritten onto `/remote` and carries the device credential exactly like every other gated call. Without the hook a paired browser's uploads bypass the channel and the harness browser-auth fence answers 401 (issue #1580). The hook is only published on non-loopback origins, only while the channel is installed, and never overwrites a hook the page already owns.
@@ -182,6 +196,7 @@ The public path is the same round trip through a tunnel (see "Remote access over
182
196
  - **Plain-HTTP LAN reopens need a re-scan**: the reopen service worker registers only on secure contexts (https tunnels, localhost); a phone paired over a plain-HTTP LAN URL that navigates back to `/` hits the harness 401 and must scan a fresh QR. Pairing again is cheap in-network and restores everything.
183
197
  - **The adaptation selectors track the official build**: the semantic-suffix strategy survives hash churn but not semantic renames; each official GUI update needs a visual QA pass (per the dsh-LAN reference, these suffixes have been stable across many official releases).
184
198
  - **Dev HMR**: `dsh web --dev` polls every roster bundle by path, so rebuilding this package (its own `tsdown --watch`) hot-reloads the client bundle; the host half needs a restart.
199
+ - **A mounted `@deepseek-ai/dsh-hmr` row blocks every settings save**: the official config editor serializes its writes through `hmr.runExclusive` whenever the `hmr` service is mounted, and on this cohort such a write is rejected as a nested transaction - the Host answers `HMR transactions cannot be nested` and each family settings card (and the official settings pages) reports a failed save. The row is user-added (no bundle ships it); a Desktop profile carries it to hot-reload a local plugin's host half. Remove the `- id: hmr` row from the profile patch to restore saves, and re-add it only while iterating on that plugin. Observed on the Desktop client 0.2.0-rc.2 (2026-09-30).
185
200
 
186
201
  ## Dependency rationale
187
202
 
package/README.zh.md CHANGED
@@ -1,7 +1,21 @@
1
- # DSH 远程访问(Remote Web UI)
1
+ # dsh-remote-web-ui · DeepSeek Harness (DSH) 手机端与跨设备浏览器远程控制插件
2
2
 
3
3
  [English](README.md) | 中文
4
- > 为 dsh web GUI 提供共享**同一份界面**的远程访问:在设置按钮旁的二维码配对手机或另一台电脑,两端运行的都和本机一样是官方 Web GUI——手机获得注入的竖屏触控适配,电脑获得完整桌面;准入基于限时配对令牌与可撤销设备会话。设置卡片可将服务绑定到局域网,可选 Cloudflare 快速隧道触达公网——并由一个永不变化的固定主机名前置,手机的书签与配对零配置跨重启有效。
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/@linxin666/dsh-client-ui-remote-web-ui?style=flat-square" alt="Version">
7
+ &nbsp;
8
+ <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.0--rc.2-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
9
+ &nbsp;
10
+ <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
11
+ </p>
12
+
13
+ <p align="center">
14
+ <strong>DeepSeek Harness(DSH)官方 Web GUI 与桌面客户端手机与跨设备远程控制中心</strong><br>
15
+ <em>手机扫码即连 · 跨设备 PC 浏览器访问 · 竖屏触控手势优化 · Cloudflare 公网隧道 · 安全设备配对</em>
16
+ </p>
17
+
18
+ > 为 DeepSeek Harness (DSH) Web GUI 与官方桌面客户端提供共享**同一份界面**的远程访问:在设置按钮旁的二维码配对手机或另一台电脑,两端运行的都和本机一样是官方 Web GUI——手机获得注入的竖屏触控适配,电脑获得完整桌面;准入基于限时配对令牌与可撤销设备会话。设置卡片可将服务绑定到局域网,可选 Cloudflare 快速隧道触达公网——并由一个永不变化的固定主机名前置,手机的书签与配对零配置跨重启有效。
5
19
 
6
20
  本仓库是 DeepSeek Harness(DSH)的外部插件包,为单一双面包:host 半区持有配对令牌、设备会话、`/api/pair` 路由族、门控 `/remote` 通道、局域网绑定开关;浏览器半区渲染侧栏底部、设置按钮旁的远程访问入口、带二维码的配对面板、实时设备状态、已授权设备列表、设置卡片,以及官方界面之上的竖屏触控适配层。
7
21
 
@@ -58,7 +72,7 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-remote-web-ui
58
72
 
59
73
  ## 使用
60
74
 
61
- 1. 打开设置卡片(设置 → Web 插件 → 远程访问设置),若服务绑定回环则打开**局域网访问**;卡片展示运行中的绑定、防火墙状态与可达局域网地址。绑定变化自下一次 `dsh web` 启动生效。
75
+ 1. 打开设置卡片(设置 → Web 插件 → 远程访问),若服务绑定回环则打开**局域网访问**;卡片展示运行中的绑定、防火墙状态与可达局域网地址。绑定变化自下一次 `dsh web` 启动生效。
62
76
  2. 启动 `dsh web`,点手机图标,面板铸造一枚全新的限时二维码。
63
77
  3. 手机扫码(或打开复制的链接):设备完成配对,进入由插件不依赖 cookie 交付的**官方 Web GUI**(`/pair-accept` → `/pair-app`),并重载到 `/`。手机上竖屏适配层已生效——与桌面同布局、同实时状态。此后的重开(历史、书签)直接回到应用(https 源;见安全模型)。
64
78
  4. **改为配对电脑**:复制同一链接,在另一台电脑的浏览器打开。相同往返后完整 Web GUI 在彼处经门控 `/remote` 通道运行;未配对电脑看到引导式拦截页,其后没有数据。一枚令牌配一台设备;下一台设备请刷新二维码。
@@ -122,7 +136,7 @@ pnpm --filter @linxin666/dsh-remote-web-ui test
122
136
  pnpm --filter @linxin666/dsh-remote-web-ui run typecheck
123
137
  ```
124
138
 
125
- 对端 API 来自官方 NPM SDK:用到的每个 `@deepseek-ai/*` 包都声明在 devDependencies(0.2.0-rc.1 cohort)中,TypeScript/Vitest 直接从 node_modules 解析类型——不需要 DSH 源码 checkout。消费侧 `prepare` 构建(`tsdown.prepare.config.ts`)不做类型检查地转译,git 安装同样无需 harness checkout。
139
+ 对端 API 来自官方 NPM SDK:用到的每个 `@deepseek-ai/*` 包都声明在 devDependencies(0.2.0-rc.2 cohort)中,TypeScript/Vitest 直接从 node_modules 解析类型——不需要 DSH 源码 checkout。消费侧 `prepare` 构建(`tsdown.prepare.config.ts`)不做类型检查地转译,git 安装同样无需 harness checkout。
126
140
 
127
141
  ## 检查
128
142
 
@@ -134,7 +148,7 @@ pnpm run build
134
148
 
135
149
  ## Harness 契约依赖
136
150
 
137
- 锚定 0.2.0-rc.1 线;本构建依赖的接缝:
151
+ 锚定 0.2.0-rc.2 线;本构建依赖的接缝:
138
152
 
139
153
  - **`sidebar.footer.action` 底部席位**(0.1.7 shell 组合):侧栏声明并渲染远程入口占据的席位。
140
154
  - **`ctx.layout.toggleSidebar()`**(packages/client/ui-layout):鲸鱼按钮经官方面板动作面展开折叠侧栏。
@@ -161,7 +175,7 @@ pnpm run build
161
175
 
162
176
  - **配对是 `/remote` 通道的访问控制,且无条件生效**:每个请求必须携带有效配对设备 cookie,在任何字节转发之前强制。缺失或被撤销的会话收到 HTTP 403,JSON 拒绝携带 `error.code: "unpaired"`;浏览器 `EventSource` API 只暴露流失败,不暴露响应体。该门**不**跟随 `requirePairingForLan`:通道会用进程自身的浏览器认证凭据重新发起每一次调用,放行未配对调用者等于把该权限交出去。局域网策略只作用于普通 `/api` 面,永远不能放宽本通道(issue #1665)。
163
177
  - **通道携带进程自己的内部凭据。** harness 浏览器认证 cookie 与 authority 绑定(为浏览器访问过的确切 `host:port` 签发)且没有回环豁免,因此转发到 `127.0.0.1` 的再发起请求无法复用设备的 cookie。插件因此自行兑换一次自己的启动令牌——与浏览器首次访问执行的是同一次交换——并把所得 cookie 附到再发起请求上。该凭据只在上面的配对门之后被使用;停止/取消配对会立即停止对它的使用。
164
- - **运行时现实:配对不门控直连 `/api`。** 官方运行时没有任何组件发出 `api/gate` seam(对照本仓库安装的 0.2.0-rc.1 包核实),因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
178
+ - **运行时现实:配对不门控直连 `/api`。** 官方运行时没有任何组件发出 `api/gate` seam(对照本仓库安装的 0.2.0-rc.2 包核实),因此来自局域网源头的直连 `/api` 仅由 harness 围栏(`0.0.0.0` 绑定下自动信任局域网字面量)加 harness 浏览器认证 cookie 约束。设备已经兑换过的浏览器凭据在停止/取消配对后仍然有效,直到其自然过期(30 天)——撤销约束的是 `/remote` 通道与配对 cookie,而不是那个凭据。插件会对 `/api` 姿态做探测并大声告警;请把局域网绑定当作深思熟虑的决定,在共享机器上优先回环加隧道。
165
179
  - **配对设备是完全控制凭据。** host 模式下它可达完整 host API——聊天、会话、设置、凭据、Agent 预设、产出物——与 SDK 对回环桌面的信任一致。只有三个控制面(配对、自更新、插件安装/卸载)保持物理本地。只配对你控制的设备;停止或逐设备取消配对立即撤销。
166
180
  - **控制端点仅限回环**:铸造/停止/撤销、设备列表与 lan-bind 状态只应答回环,门控通道同时让自更新与插件安装路径保持物理本地。局域网源浏览器看到「配对面板仅限本机使用」横幅。
167
181
  - **后台文件上传同样走通道。** 官方上传服务优先使用 Web Worker 载体,其独立全局对象不受主线程补丁影响;因此引导补丁(以及作为兜底的浏览器补丁)会发布官方启动前钩子 `__DSH_FILE_UPLOAD__`,并把打过补丁的 `fetch` 交给它:原始 `/api/session/uploadFileBinary` POST 会被改写到 `/remote`,并像其他受门控调用一样携带设备凭据。没有该钩子时,配对浏览器的上传会绕过通道,被 harness 浏览器认证围栏拒为 401(issue #1580)。该钩子仅在非回环源、且通道安装期间发布,且永不覆盖页面已有的钩子。
@@ -182,6 +196,7 @@ pnpm run build
182
196
  - **纯 HTTP 局域网的重开需要重扫**:重开 service worker 只在安全上下文(https 隧道、localhost)注册;经纯 HTTP 局域网 URL 配对的手机导航回 `/` 时会撞上 harness 401,需要重新扫码。局域网内重配对成本很低,配对后一切恢复。
183
197
  - **适配选择器跟随官方构建**:语义后缀策略可在 hash 变化中存活,但语义改名不行;每次官方 GUI 升级需要一轮视觉 QA(参考 dsh-LAN,这些后缀在多次官方发布中保持稳定)。
184
198
  - **开发 HMR**:`dsh web --dev` 按路径轮询每个 roster bundle,重建本包(自身 `tsdown --watch`)即热重载客户端 bundle;host 半区需要重启。
199
+ - **profile 里挂了 `@deepseek-ai/dsh-hmr` 行会让所有设置保存失败**:只要 `hmr` 服务存在,官方配置编辑器就会把写入包进 `hmr.runExclusive`,而在本 cohort 上这种写入会被判为嵌套事务——Host 返回 `HMR transactions cannot be nested`,本插件的各设置卡片(以及官方设置页)都显示保存失败。该行由用户自行添加(没有任何 bundle 自带),桌面版 profile 用它为本地插件的 host 半区做热重载。从 profile 补丁里删掉 `- id: hmr` 这一行即可恢复保存;只在需要热重载该插件时再加回。观察于 2026-09-30,桌面版客户端 0.2.0-rc.2。
185
200
 
186
201
  ## 依赖说明
187
202
 
package/lib/client.js CHANGED
@@ -2114,7 +2114,7 @@ window.__ModuleLoader__.load({
2114
2114
  }
2115
2115
  //#endregion
2116
2116
  //#region \0dsh-css:packages/dsh-remote-web-ui/src/client/settings-card.module.css.mjs
2117
- const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;box-sizing:border-box;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{box-sizing:border-box;border-radius:12px;align-items:center;gap:12px;width:100%;padding:14px 16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-secondary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-state-error-primary,#b42318);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-state-error-primary,#b42318);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-state-error-primary,#b42318);outline-offset:1px;border-color:var(--dsw-alias-state-error-primary,#b42318)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-state-error-primary,#b42318);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2117
+ const css = ".Kwoi6G_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}.Kwoi6G_card:hover{border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_header{appearance:none;box-sizing:border-box;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:13px;padding:16px;display:flex}.Kwoi6G_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.Kwoi6G_headerStatic{box-sizing:border-box;border-radius:12px;align-items:center;gap:13px;width:100%;padding:16px;display:flex}.Kwoi6G_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.Kwoi6G_mark{background:var(--dsw-alias-bg-module-platform);width:34px;height:34px;color:var(--dsw-alias-label-secondary);border-radius:10px;flex:none;place-items:center;transition:color .16s;display:grid}.Kwoi6G_header:hover .Kwoi6G_mark,.Kwoi6G_cardOpen .Kwoi6G_mark{color:var(--dsw-alias-label-primary)}.Kwoi6G_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.Kwoi6G_description{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5}.Kwoi6G_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_chevron{color:var(--dsw-alias-label-tertiary);flex:none;width:16px;height:16px;transition:transform .16s,color .16s}.Kwoi6G_header:hover .Kwoi6G_chevron{color:var(--dsw-alias-label-secondary)}.Kwoi6G_chevronOpen{transform:rotate(180deg)}.Kwoi6G_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}.Kwoi6G_readOnly{color:var(--dsw-alias-label-secondary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_notExposed{color:var(--dsw-alias-state-warn-primary);margin:12px 0 0;font-size:12px;line-height:1.5}.Kwoi6G_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}.Kwoi6G_failed{min-width:0;color:var(--dsw-alias-state-error-primary,#b42318);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:12px;line-height:1.5;overflow:hidden}.Kwoi6G_discard,.Kwoi6G_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}.Kwoi6G_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}.Kwoi6G_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}.Kwoi6G_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}.Kwoi6G_discard:disabled,.Kwoi6G_save:disabled{opacity:.4;cursor:default}.Kwoi6G_discard:focus-visible,.Kwoi6G_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.Kwoi6G_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.Kwoi6G_field+.Kwoi6G_field{border-top:1px solid var(--dsw-alias-border-l2)}.Kwoi6G_head{align-items:center;gap:8px;display:flex}.Kwoi6G_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.Kwoi6G_badges{align-items:center;gap:8px;display:inline-flex}.Kwoi6G_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.Kwoi6G_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.Kwoi6G_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.Kwoi6G_reset:disabled{cursor:default}.Kwoi6G_reset:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.Kwoi6G_input,.Kwoi6G_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_input:focus-visible,.Kwoi6G_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.Kwoi6G_input:disabled,.Kwoi6G_select:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.Kwoi6G_inputInvalid{border:1px solid var(--dsw-alias-state-error-primary,#b42318);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.Kwoi6G_inputInvalid:focus-visible{outline:2px solid var(--dsw-alias-state-error-primary,#b42318);outline-offset:1px;border-color:var(--dsw-alias-state-error-primary,#b42318)}.Kwoi6G_selectWrap{position:relative}.Kwoi6G_selectButton{appearance:none;text-align:left;cursor:pointer;justify-content:space-between;align-items:center;gap:8px;width:100%;display:flex}.Kwoi6G_selectLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.Kwoi6G_selectChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.Kwoi6G_selectChevronOpen{transform:rotate(180deg)}.Kwoi6G_selectPopup{z-index:40;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);max-height:240px;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-2);opacity:0;border-radius:8px;flex-direction:column;padding:4px;transition:opacity .1s,transform .1s;display:flex;position:absolute;top:calc(100% + 4px);left:0;right:0;overflow-y:auto;transform:translateY(-4px)}.Kwoi6G_selectPopupOpen{opacity:1;transform:none}.Kwoi6G_selectPopupClose{opacity:0;pointer-events:none;transform:translateY(-4px)}.Kwoi6G_selectOption{color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex-shrink:0;padding:6px 10px;font-size:13px;line-height:1.5;overflow:hidden}.Kwoi6G_selectOption:hover,.Kwoi6G_selectOptionActive{background:var(--dsw-alias-interactive-bg-hover)}.Kwoi6G_selectOptionSelected{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);font-weight:500}.Kwoi6G_invalid{color:var(--dsw-alias-state-error-primary,#b42318);margin:0;font-size:12px;line-height:1.5}.Kwoi6G_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:1.5}@media (prefers-reduced-motion:reduce){.Kwoi6G_card,.Kwoi6G_header,.Kwoi6G_mark,.Kwoi6G_chevron,.Kwoi6G_chevronOpen,.Kwoi6G_discard,.Kwoi6G_save,.Kwoi6G_selectChevron,.Kwoi6G_selectChevronOpen,.Kwoi6G_selectPopup{transition:none}}";
2118
2118
  const tagId = "@linxin666/dsh-remote-web-ui/packages/dsh-remote-web-ui/src/client/settings-card.module.css";
2119
2119
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
2120
2120
  const tag = document.createElement("style");
@@ -2145,6 +2145,7 @@ window.__ModuleLoader__.load({
2145
2145
  "inputInvalid": "Kwoi6G_inputInvalid",
2146
2146
  "invalid": "Kwoi6G_invalid",
2147
2147
  "label": "Kwoi6G_label",
2148
+ "mark": "Kwoi6G_mark",
2148
2149
  "name": "Kwoi6G_name",
2149
2150
  "notExposed": "Kwoi6G_notExposed",
2150
2151
  "pending": "Kwoi6G_pending",
@@ -2190,22 +2191,30 @@ window.__ModuleLoader__.load({
2190
2191
  const cardClass = expanded ? `${settings_card_module_css_default.cardOpen} ${settings_card_module_css_default.card}` : settings_card_module_css_default.card;
2191
2192
  const header = alwaysOpen === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2192
2193
  className: settings_card_module_css_default.headerStatic,
2193
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2194
- className: settings_card_module_css_default.headText,
2195
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2196
- className: settings_card_module_css_default.name,
2197
- title,
2198
- children: title
2199
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2200
- className: settings_card_module_css_default.description,
2201
- title: description,
2202
- children: props.descriptionNode ?? description
2203
- })]
2204
- }), state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2205
- className: settings_card_module_css_default.pending,
2206
- title: props.t("settings.unsaved"),
2207
- children: props.t("settings.unsaved")
2208
- }) : null]
2194
+ children: [
2195
+ props.icon !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2196
+ className: settings_card_module_css_default.mark,
2197
+ "aria-hidden": "true",
2198
+ children: props.icon
2199
+ }) : null,
2200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2201
+ className: settings_card_module_css_default.headText,
2202
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2203
+ className: settings_card_module_css_default.name,
2204
+ title,
2205
+ children: title
2206
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2207
+ className: settings_card_module_css_default.description,
2208
+ title: description,
2209
+ children: props.descriptionNode ?? description
2210
+ })]
2211
+ }),
2212
+ state.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2213
+ className: settings_card_module_css_default.pending,
2214
+ title: props.t("settings.unsaved"),
2215
+ children: props.t("settings.unsaved")
2216
+ }) : null
2217
+ ]
2209
2218
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2210
2219
  type: "button",
2211
2220
  className: settings_card_module_css_default.header,
@@ -2215,6 +2224,11 @@ window.__ModuleLoader__.load({
2215
2224
  setOpen(!open);
2216
2225
  },
2217
2226
  children: [
2227
+ props.icon !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2228
+ className: settings_card_module_css_default.mark,
2229
+ "aria-hidden": "true",
2230
+ children: props.icon
2231
+ }) : null,
2218
2232
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2219
2233
  className: settings_card_module_css_default.headText,
2220
2234
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2663,6 +2677,8 @@ window.__ModuleLoader__.load({
2663
2677
  disposeForm;
2664
2678
  disposed = false;
2665
2679
  saving = false;
2680
+ /** A save asked for while one was in flight; it runs once the first settles (#1754). */
2681
+ saveQueued = false;
2666
2682
  failed = false;
2667
2683
  failedReason;
2668
2684
  /** @param scope - the bound configuration form for this card's namespace. */
@@ -2738,7 +2754,7 @@ window.__ModuleLoader__.load({
2738
2754
  });
2739
2755
  },
2740
2756
  save: () => {
2741
- this.save();
2757
+ this.requestSave();
2742
2758
  },
2743
2759
  discard: () => {
2744
2760
  if (this.staged.size === 0 && !this.failed) return;
@@ -2765,6 +2781,29 @@ window.__ModuleLoader__.load({
2765
2781
  * the user can correct them instead of retyping.
2766
2782
  * @returns settlement after the mutation and the read-back.
2767
2783
  */
2784
+ /**
2785
+ * Run one save, and re-run it once if another was asked for while this one
2786
+ * was still in flight.
2787
+ *
2788
+ * A save is a Host round trip that also drives the profile reconcile, and
2789
+ * the Host runs that write inside one exclusive transaction. Answering a
2790
+ * press that arrives mid-flight by returning immediately dropped the edit
2791
+ * with no explanation, which is the "the save button stops working after a
2792
+ * few rounds" report (#1754). Serializing instead means a save pressed while
2793
+ * another is still settling runs against the settled state - which is what
2794
+ * the operator meant by pressing it again.
2795
+ * @returns settlement after the mutation and the read-back.
2796
+ */
2797
+ async requestSave() {
2798
+ if (this.saving) {
2799
+ this.saveQueued = true;
2800
+ return;
2801
+ }
2802
+ await this.save();
2803
+ if (!this.saveQueued) return;
2804
+ this.saveQueued = false;
2805
+ await this.requestSave();
2806
+ }
2768
2807
  async save() {
2769
2808
  const plan = this.plan();
2770
2809
  const valid = plan.filter((item) => item.judge !== void 0);
@@ -2978,6 +3017,7 @@ window.__ModuleLoader__.load({
2978
3017
  t,
2979
3018
  titleKey: "settings.title",
2980
3019
  descriptionKey: "settings.description",
3020
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PhoneIcon, { size: 18 }),
2981
3021
  defaultOpen: false,
2982
3022
  state,
2983
3023
  onSave: props.save,
@@ -3221,8 +3261,6 @@ window.__ModuleLoader__.load({
3221
3261
  const zh = {
3222
3262
  "entry.label": "远程访问",
3223
3263
  "mobile.whale.open": "打开侧边栏",
3224
- "mobile.composer.pickModel": "选择模型",
3225
- "mobile.composer.pickEffort": "选择推理等级",
3226
3264
  "title": "远程访问",
3227
3265
  "subtitle": "通过手机或另一台电脑配对,远程使用同一份 Web 界面(官方界面 + 移动端适配)",
3228
3266
  "card.title": "设备配对",
@@ -3232,7 +3270,7 @@ window.__ModuleLoader__.load({
3232
3270
  "status.disconnected": "已配对设备离线",
3233
3271
  "status.stopped": "已停止远程访问",
3234
3272
  "status.lanRequired": "此功能需要局域网绑定或公网地址才能使用",
3235
- "status.lanRequiredHint": "当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问设置」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。",
3273
+ "status.lanRequiredHint": "当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。",
3236
3274
  "status.loopbackRequired": "配对面板仅限本机使用",
3237
3275
  "status.loopbackRequiredHint": "请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。",
3238
3276
  "status.unreachable": "无法连接配对服务",
@@ -3288,8 +3326,8 @@ window.__ModuleLoader__.load({
3288
3326
  "relay.registering": "正在把临时隧道同步到固定域名(<id>.dsh-market.com)…",
3289
3327
  "relay.failed": "固定域名同步失败:{error}。正在自动重试,期间二维码可能使用临时地址。",
3290
3328
  "close.label": "关闭远程访问面板",
3291
- "settings.title": "远程访问设置",
3292
- "settings.description": "配对安全与设备限额。",
3329
+ "settings.title": "远程访问",
3330
+ "settings.description": "手机配对、公网隧道与设备限额。",
3293
3331
  "settings.enabled": "启用远程访问",
3294
3332
  "settings.enabledHint": "关闭后移除侧边栏入口并停用配对路由与局域网栅栏。",
3295
3333
  "settings.tokenTtlMs": "配对令牌有效期(毫秒)",
@@ -3343,8 +3381,6 @@ window.__ModuleLoader__.load({
3343
3381
  const en = {
3344
3382
  "entry.label": "Remote access",
3345
3383
  "mobile.whale.open": "Open sidebar",
3346
- "mobile.composer.pickModel": "Pick model",
3347
- "mobile.composer.pickEffort": "Pick reasoning effort",
3348
3384
  "title": "Remote access",
3349
3385
  "subtitle": "Pair a phone or another computer to share the same Web GUI (official UI + mobile adaptation)",
3350
3386
  "card.title": "Pair a device",
@@ -3410,8 +3446,8 @@ window.__ModuleLoader__.load({
3410
3446
  "relay.registering": "Syncing the ephemeral tunnel to the stable hostname (<id>.dsh-market.com)…",
3411
3447
  "relay.failed": "Stable-hostname sync failed: {error}. Retrying automatically; the QR may use the ephemeral address meanwhile.",
3412
3448
  "close.label": "Close remote access panel",
3413
- "settings.title": "Remote access settings",
3414
- "settings.description": "Pairing security and device limits.",
3449
+ "settings.title": "Remote access",
3450
+ "settings.description": "Phone pairing, public tunnel and device limits.",
3415
3451
  "settings.enabled": "Enable remote access",
3416
3452
  "settings.enabledHint": "When off, the sidebar entry is removed and pairing routes plus the LAN fence stop.",
3417
3453
  "settings.tokenTtlMs": "Pairing token lifetime (ms)",
@@ -4081,7 +4117,7 @@ window.__ModuleLoader__.load({
4081
4117
  /** The building package's version, when the bundle carries it. */
4082
4118
  function bakedVersion() {
4083
4119
  try {
4084
- return "0.4.4";
4120
+ return "0.4.6";
4085
4121
  } catch {
4086
4122
  return;
4087
4123
  }
@@ -4167,12 +4203,6 @@ window.__ModuleLoader__.load({
4167
4203
  const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`;
4168
4204
  /** Whale button id. */
4169
4205
  const WHALE_ID = "dshRemoteWhale";
4170
- /** Compact picker: synthesized model button id. */
4171
- const MODEL_BTN_ID = "dshRemoteModelPick";
4172
- /** Compact picker: synthesized effort button id. */
4173
- const EFFORT_BTN_ID = "dshRemoteEffortPick";
4174
- /** Body class while the compact picker buttons are wired. */
4175
- const COMPACT_CLASS = "dsh-remote-compact-picker";
4176
4206
  /** Body class while the header actions are seated in the tabs row. */
4177
4207
  const HEADER_SEATED_CLASS = "dsh-remote-header-seated";
4178
4208
  /**
@@ -4181,16 +4211,6 @@ window.__ModuleLoader__.load({
4181
4211
  * reserving the space with padding.
4182
4212
  */
4183
4213
  const HEADER_RESERVE_VAR = "--dsh-remote-header-actions-reserve";
4184
- /** Locale-dependent fast path for the official picker cells (zh/en). */
4185
- const PICKER_CELL_PATTERN = {
4186
- model: /模型|Model/,
4187
- effort: /推理等级|Reasoning|Effort/i
4188
- };
4189
- /** Position of each drill cell among the sheet's chevron cells. */
4190
- const DRILL_INDEX = {
4191
- model: 0,
4192
- effort: 1
4193
- };
4194
4214
  /**
4195
4215
  * The official application frame. The layout column classes are the anchor:
4196
4216
  * `_frame` is shared by unrelated official components (the chat turn rail,
@@ -4255,9 +4275,9 @@ window.__ModuleLoader__.load({
4255
4275
  "[class$=\"_headerUtilities\"]{display:none}",
4256
4276
  "[class$=\"_composerSeat\"] [class$=\"_card\"]{margin-bottom:1px}",
4257
4277
  "[class$=\"_composerSeat\"] [class$=\"_row\"]{flex-wrap:wrap;row-gap:0;padding:2px 8px 1px;position:relative}",
4258
- "[class$=\"_composerSeat\"] [class$=\"_add\"]{position:absolute;left:8px;top:50%;transform:translateY(-50%)}",
4278
+ "[class$=\"_composerSeat\"] [class$=\"_add\"]{position:absolute;left:8px;top:50%;transform:translateY(-50%);z-index:10}",
4259
4279
  "[class$=\"_composerSeat\"] [class$=\"_modes\"]{min-width:0;padding-left:38px}",
4260
- "[class$=\"_composerSeat\"] [class$=\"_trailing\"]{flex-basis:100%;position:relative;min-height:32px;justify-content:flex-start;padding-left:38px;padding-right:78px}",
4280
+ "[class$=\"_composerSeat\"] [class$=\"_trailing\"]{box-sizing:border-box;flex-basis:100%;min-height:32px;justify-content:flex-start;padding-left:38px;padding-right:78px}",
4261
4281
  "[class$=\"_composerSeat\"] [class$=\"_trailing\"] *{font-size:12px}",
4262
4282
  "[class$=\"_composerSeat\"] [class$=\"_modes\"] [class$=\"_trigger\"]{height:24px;min-height:24px;font-size:12px}",
4263
4283
  "[class$=\"_composerSeat\"] [class$=\"_trailing\"] [class$=\"_trigger\"]{height:24px;min-height:24px;font-size:11px}",
@@ -4266,12 +4286,6 @@ window.__ModuleLoader__.load({
4266
4286
  "[class$=\"_composerSeat\"]{transform:none !important}",
4267
4287
  "[class$=\"_composerSeat\"] [class$=\"_menu\"]{position:fixed !important;left:8px !important;right:8px !important;top:auto !important;bottom:calc(8px + env(safe-area-inset-bottom)) !important;width:auto !important;max-width:none !important;max-height:70dvh !important;overflow-y:auto !important;z-index:2147482000}",
4268
4288
  "[class$=\"_composerSeat\"] [class$=\"_menu\"] [class$=\"_cell\"]{height:44px;min-height:44px;font-size:13px}",
4269
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] [class$="_trigger"]:has([class$="_triggerEffort"]){display:none}`,
4270
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"]{flex-basis:auto;position:static;min-height:0;padding:0;width:0}`,
4271
- `body.${COMPACT_CLASS} [class$="_composerSeat"] [class$="_trailing"] > [class$="_root"]:has([class$="_track"]){right:44px}`,
4272
- `#${MODEL_BTN_ID},#${EFFORT_BTN_ID}{width:26px;height:32px;min-width:26px;padding:0;border-radius:9px;background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:none;margin-left:4px}`,
4273
- `#${MODEL_BTN_ID} svg,#${EFFORT_BTN_ID} svg{width:16px;height:16px;display:block}`,
4274
- `#${MODEL_BTN_ID}:active,#${EFFORT_BTN_ID}:active{opacity:.7}`,
4275
4289
  "[class$=\"_composerSeat\"] [data-slot=\"conversation.composer.dock\"] [class$=\"_root\"]{font-size:10px;white-space:normal;word-break:break-word;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:left;line-height:13px;letter-spacing:-0.2px;padding-left:0;padding-right:0}",
4276
4290
  "[class$=\"_scrollBody\"] [class$=\"_root\"]{font-size:13px}",
4277
4291
  "[class*=\"_bubble\"][role=\"tooltip\"]{display:none}",
@@ -4290,18 +4304,14 @@ window.__ModuleLoader__.load({
4290
4304
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"pet\"],`,
4291
4305
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"usage\"]{display:none !important}`,
4292
4306
  `body.${ACTIVE_CLASS} [class$=\"_overlayLayer\"] [class$=\"_workbench\"]{display:none !important}`,
4293
- "[class$=\"_overlay\"] [class$=\"_panel\"]{flex-direction:column;max-height:calc(100dvh - 32px)}",
4294
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_nav\"]{flex-direction:row;gap:4px;width:100%;padding:12px 12px 0;overflow-x:auto;overflow-y:hidden}",
4295
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_navTitle\"]{display:none}",
4296
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_navList\"]{flex-direction:row;gap:4px}",
4297
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_navCell\"]{height:34px;padding:0 12px;gap:6px;flex:none;border-radius:10px}",
4298
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_navLabel\"]{font-size:13px}",
4299
- "[class$=\"_overlay\"] [class$=\"_panel\"] [class$=\"_content\"]{flex:1;min-height:0}"
4307
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"]{flex-direction:column;max-height:calc(100dvh - 32px)}`,
4308
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_nav"]{flex-direction:row;gap:4px;width:100%;padding:12px 12px 0;overflow-x:auto;overflow-y:hidden}`,
4309
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navTitle"]{display:none}`,
4310
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navList"]{flex-direction:row;gap:4px}`,
4311
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navCell"]{height:34px;padding:0 12px;gap:6px;flex:none;border-radius:10px}`,
4312
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_navLabel"]{font-size:13px}`,
4313
+ `body.${ACTIVE_CLASS} [class$="_overlay"] [class$="_panel"] [class$="_content"]{flex:1;min-height:0}`
4300
4314
  ];
4301
- /** Cube glyph for the compact model button (a plain box outline). */
4302
- const CUBE_ICON = "<path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>";
4303
- /** Level glyph for the compact effort button (three rising bars). */
4304
- const LEVELS_ICON = "<path d=\"M4 6h16\"/><path d=\"M7 12h10\"/><path d=\"M10 18h4\"/>";
4305
4315
  /** The DeepSeek fish glyph (the official brand mark path). */
4306
4316
  const FISH_PATH = "M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018Z";
4307
4317
  /**
@@ -4386,7 +4396,6 @@ window.__ModuleLoader__.load({
4386
4396
  active = false;
4387
4397
  unseatHeaderActions();
4388
4398
  restoreRowDrag();
4389
- removeCompactPicker();
4390
4399
  document.body.classList.remove(ACTIVE_CLASS);
4391
4400
  document.body.classList.remove(RAIL_HIDDEN_CLASS);
4392
4401
  document.querySelector(`style[data-plugin-css="${ADAPT_CSS_ID}"]`)?.remove();
@@ -4446,79 +4455,6 @@ window.__ModuleLoader__.load({
4446
4455
  }
4447
4456
  }
4448
4457
  /**
4449
- * Compact picker (v79): a phone row cannot fit the desktop text triggers,
4450
- * so the model/effort entries become two icon buttons in the trailing
4451
- * row. Both forward to the official picker trigger (its menu renders as
4452
- * the bottom sheet) and then drill straight into the asked cell — model
4453
- * list or effort list — so one tap lands on the list, matching the
4454
- * cube-model / brain-effort mapping. The official context ring next to
4455
- * the send button keeps its own semantics untouched.
4456
- */
4457
- function removeCompactPicker() {
4458
- document.body.classList.remove(COMPACT_CLASS);
4459
- document.getElementById(MODEL_BTN_ID)?.remove();
4460
- document.getElementById(EFFORT_BTN_ID)?.remove();
4461
- }
4462
- function drillIntoPicker(kind) {
4463
- const trigger = document.querySelector("[class$=\"_composerSeat\"] [class$=\"_trailing\"] [class$=\"_trigger\"]:has([class$=\"_triggerEffort\"])");
4464
- if (trigger === null) return;
4465
- trigger.click();
4466
- let tries = 0;
4467
- const tapCell = () => {
4468
- tries += 1;
4469
- const cells = Array.from(document.querySelectorAll("[class$=\"_composerSeat\"] [class$=\"_menu\"] [class$=\"_cell\"]"));
4470
- const byLabel = cells.find((c) => PICKER_CELL_PATTERN[kind].test(c.textContent ?? ""));
4471
- const drillable = cells.filter((c) => c.querySelector("[class*=\"_cellChevron\"], [class*=\"_chevron\"]"));
4472
- const cell = byLabel ?? drillable[DRILL_INDEX[kind]];
4473
- if (cell !== void 0) {
4474
- cell.click();
4475
- return;
4476
- }
4477
- if (tries < 8) window.setTimeout(tapCell, 150);
4478
- };
4479
- window.setTimeout(tapCell, 150);
4480
- }
4481
- function makeCompactButton(id, title, icon, kind) {
4482
- const btn = document.createElement("button");
4483
- btn.id = id;
4484
- btn.type = "button";
4485
- btn.dataset.dshPlugin = "remote-web-ui";
4486
- btn.title = title;
4487
- btn.setAttribute("aria-label", title);
4488
- btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`;
4489
- btn.addEventListener("click", () => {
4490
- drillIntoPicker(kind);
4491
- });
4492
- return btn;
4493
- }
4494
- function syncCompactPicker() {
4495
- if (!active) return;
4496
- const tools = nodeOf("[class$=\"_composerSeat\"] [class$=\"_tools\"]");
4497
- const trigger = tools?.parentElement?.querySelector("[class$=\"_triggerEffort\"]")?.parentElement;
4498
- if (tools === null || trigger === null) {
4499
- removeCompactPicker();
4500
- return;
4501
- }
4502
- if (document.getElementById(MODEL_BTN_ID) === null) tools.appendChild(makeCompactButton(MODEL_BTN_ID, surfaceText("mobile.composer.pickModel", "Pick model"), CUBE_ICON, "model"));
4503
- if (document.getElementById(EFFORT_BTN_ID) === null) tools.appendChild(makeCompactButton(EFFORT_BTN_ID, surfaceText("mobile.composer.pickEffort", "Pick reasoning effort"), LEVELS_ICON, "effort"));
4504
- for (const [id, key, fallback] of [[
4505
- MODEL_BTN_ID,
4506
- "mobile.composer.pickModel",
4507
- "Pick model"
4508
- ], [
4509
- EFFORT_BTN_ID,
4510
- "mobile.composer.pickEffort",
4511
- "Pick reasoning effort"
4512
- ]]) {
4513
- const btn = document.getElementById(id);
4514
- if (btn === null) continue;
4515
- const label = surfaceText(key, fallback);
4516
- btn.title = label;
4517
- btn.setAttribute("aria-label", label);
4518
- }
4519
- document.body.classList.add(COMPACT_CLASS);
4520
- }
4521
- /**
4522
4458
  * The official sidebar toggle in the logo row. The row's own `_toggle` class
4523
4459
  * is the precise anchor; the row's last button is the fallback for cohorts
4524
4460
  * that only expose the generic icon-button class (the first button is the
@@ -4807,7 +4743,6 @@ window.__ModuleLoader__.load({
4807
4743
  disableRowDrag();
4808
4744
  seatHeaderActions();
4809
4745
  alignActionsText();
4810
- syncCompactPicker();
4811
4746
  }
4812
4747
  function seatHeaderActions() {
4813
4748
  if (!active) return;
@@ -5302,11 +5237,13 @@ window.__ModuleLoader__.load({
5302
5237
  ...seat.order === void 0 ? {} : { order: seat.order },
5303
5238
  ...seat.label === void 0 ? {} : { label: seat.label },
5304
5239
  locale: seat.locale,
5240
+ ...seat.children === void 0 ? {} : { children: seat.children },
5305
5241
  ...seat.inject === void 0 ? {} : { inject }
5306
5242
  } : {
5307
5243
  name: OFFICIAL_PLUGIN_CARD_SEAT,
5308
5244
  key: seat.bundle,
5309
5245
  locale: seat.locale,
5246
+ ...seat.children === void 0 ? {} : { children: seat.children },
5310
5247
  ...seat.inject === void 0 ? {} : { inject }
5311
5248
  }, component);
5312
5249
  current = target;