dsh-chat-embed 0.0.0-stage → 0.5.4

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.
@@ -0,0 +1,11 @@
1
+ # dsh-chat-embed — bundle composition.
2
+ #
3
+ # Installed into a dsh profile through `plugin_manager install_bundle` or
4
+ # `dsh plugin add <spec>`; the loader row below inserts this bundle's plugin
5
+ # entry into that profile's composition.
6
+ #
7
+ # id — the loader entry id, unique inside the profile.
8
+ # name — must equal this package's `name` field.
9
+ - insert:
10
+ - id: chat-embed
11
+ name: 'dsh-chat-embed'
@@ -0,0 +1,376 @@
1
+ # 架构与内嵌契约(dsh-chat-embed)
2
+
3
+ 本文记录 `client.js` 里每一条**反直觉**的实现决策、支撑它的证据,以及升级桌面壳时必须重新验证的清单。
4
+
5
+ 目标运行环境:**`@deepseek-ai/dsh-desktop 0.2.2-rc.1`(Electron / Chromium)**。
6
+ 当前实现:**v0.5.4**(`client.js` 约 3545 行,62 项契约测试)。
7
+
8
+ > **架构一句话**:聊天页直接渲染在**席位的布局盒**里(`.dce-panel.dce-inline`,`width/height:100%`),几何完全交给 CSS;席位只有两个 —— 中央主面板(`main`)和右栏聊天 tab —— 二者互斥,**哪边点哪边接管**,切换时 guest 重建一次并回到上次会话。
9
+
10
+ ---
11
+
12
+ ## 0. 结论速览
13
+
14
+ | 问题 | 结论 |
15
+ | --- | --- |
16
+ | 能用 iframe 吗 | ❌ 站点返回 `frame-ancestors 'none'`,任何源都拒绝被嵌入 |
17
+ | 能用 `<webview>` 吗 | ✅ 但必须持壳下发的**租约**,且三个属性完全一致 |
18
+ | 插件能拿到租约吗 | ✅ 客户端插件 bundle 以**同源 classic script** 注入,跑在 Electron 主世界,能读 `window.dshDesktop` |
19
+ | 挂上以后怎么导航 | 只能从 guest **文档内部**导航(`location.href`);`loadURL()` / 改 `src` 无效 |
20
+ | 登录态能撑多久 | 未打壳补丁:仅本次运行有效;**打补丁后**每个服务固定 disk 分区,跨重启免登录 |
21
+ | 切换标签要重载吗 | 不用:guest 常驻,切标签只切 `data-active`(`display:none`) |
22
+ | 切席主要重载吗 | 会重建一次(WebContents 随 DOM 销毁),随后用 `startUrlFor()` 回到上次会话地址 |
23
+ | 两个席位能同时显示吗 | ❌ 互斥:右栏属于会话视图,中央打开聊天面板后右栏不可用 |
24
+ | 多标签怎么隔离 | 每个服务一个 `workspace` 身份 → 独立分区(独立 cookie 罐);每个服务一个 guest,各自一条租约 |
25
+
26
+ ---
27
+
28
+ ## 1. 证据来源
29
+
30
+ 本文的事实不是从参考插件抄的,而是从本机安装的桌面壳产物里反查出来的:
31
+
32
+ | 证据 | 位置 | 内容 |
33
+ | --- | --- | --- |
34
+ | `webviewTag` 开启 | `app.asar/lib/main.js`(主窗口创建处) | `webviewTag: primary` —— 主应用窗口允许 `<webview>` |
35
+ | 租约门禁 | `app.asar/lib/main.js`,`DesktopBrowserGuests.bind()` | `will-attach-webview` 校验 `src === about:blank#<lease>`、`partition === lease.partition`、租约未用过 |
36
+ | 租约签发 | 同文件 `DesktopBrowserGuests.acquire()` | 每个 workspace 身份缓存一个 `dsh-sidebar-browser-<uuid>` 分区;每次 acquire 发新 `lease` |
37
+ | 渲染进程桥 | `app.asar/lib/preload-app.cjs` | `contextBridge.exposeInMainWorld('dshDesktop', …)`,其中 `browser.acquire / release / onOpenRequested` |
38
+ | bundle 注入方式 | `@deepseek-ai/dsh-client-modules/lib/client.js` | `defaultLoadBundle` 用 `document.createElement('script')` 注入 → 主世界执行 |
39
+ | 中央面板渲染 | `@deepseek-ai/dsh-client-ui-layout/lib/client.js` | `renderSlot("main", {}, { entryKey: panelId ?? "conversation" })` |
40
+ | 右侧栏互斥 | 同壳 `sidebarRight` 服务 | 没有会话表面时 `openTab()` 抛 `no session surface is mounted` |
41
+
42
+ > 备注:0.2.0-rc.2 与 0.2.2-rc.1 的租约门禁实现一致;0.2.2-rc.1 额外把 `allowPopups` 相关偏好清空,只保留 `disablePopups`。
43
+
44
+ ---
45
+
46
+ ## 2. 为什么 iframe 必然失败
47
+
48
+ ```text
49
+ $ curl -sI https://chat.deepseek.com/
50
+ content-security-policy: frame-ancestors 'none'
51
+ ```
52
+
53
+ dsh Web GUI 跑在自定义协议/HTTP 源上,被 `frame-ancestors 'none'` 直接拒绝。这不是配置问题,浏览器内核层面无解。
54
+
55
+ `<webview>` 是独立的顶层 WebContents,不受该指令约束 —— 这是唯一可行的嵌入容器,也决定了**功能只存在于桌面版**。
56
+
57
+ ---
58
+
59
+ ## 3. 租约门禁:三个必须同时成立的条件
60
+
61
+ 壳在 `will-attach-webview` 里校验,任何一条不满足就**直接拒绝挂载**(元素留在 DOM 里但不导航,诊断里表现为一直 `about:blank`):
62
+
63
+ 1. `src` 恰好是 `about:blank#<lease>`;
64
+ 2. `partition` 恰好是该租约签发的 partition;
65
+ 3. 租约**未被使用过**(一次性)。
66
+
67
+ 因此 `mountGuest()` 的顺序被固定为:`acquire(workspace)` → 用返回的 lease 建元素 → 绑定监听 → 插进宿主 → 从文档内部导航。**重试必须换新租约**,旧租约不可复用。
68
+
69
+ ### 3.1 `params.useragent` 为什么能活下来
70
+
71
+ 壳只清理 `allowPopups` 一类偏好,没有清 `useragent`,所以元素上的 `useragent` 属性生效 —— 这是绕过「使用环境异常」风控的前提。
72
+
73
+ ---
74
+
75
+ ## 4. 插件为什么能拿到主世界桥
76
+
77
+ 客户端插件 bundle 通过 `document.createElement('script')` 注入,**不是** ESM、也不走 preload 的隔离世界,所以:
78
+
79
+ - `window.dshDesktop` 直接可读;
80
+ - `require('react')` 拿到的是壳共享的那一份 React(装第二份会破坏 hooks);
81
+ - 因此本插件不需要任何 Host 侧配合(`index.js` 目前是空壳)。
82
+
83
+ ---
84
+
85
+ ## 5. 登录态的边界
86
+
87
+ 壳的 `acquire()`(**未打补丁时**):
88
+
89
+ ```text
90
+ partition = "dsh-sidebar-browser-" + randomUUID() // 无 persist: 前缀
91
+ partitions.set(workspace, partition) // 仅进程内缓存
92
+ ```
93
+
94
+ 三个结论:
95
+
96
+ 1. **分区是内存分区**:Electron 只对 `persist:` 前缀的分区落盘;
97
+ 2. **分区名每次启动重新随机**:即使落盘也无法命中上一次的分区;
98
+ 3. 因此「登录一次、长期复用」**只在同一次应用运行内成立**(跨面板切换、跨页面刷新、跨 HMR 重载都有效)。
99
+
100
+ 插件侧无法绕过:租约与分区都由主进程签发,渲染进程只能照单接收。
101
+
102
+ ### 5.1 为什么「自己备份 cookie」也不行
103
+
104
+ 登录态落在 `ds_session_id` / `HWWAFSESID` / `HWWAFSESTIME` 这些 **HttpOnly** cookie 上(站点响应头实测):
105
+
106
+ ```text
107
+ Set-Cookie: ds_session_id=…; Path=/; HttpOnly; Secure; SameSite=Strict
108
+ ```
109
+
110
+ HttpOnly 对 guest 页面的 `document.cookie` 不可见,而插件只拿到 `<webview>` 元素、拿不到它的 `session` 对象(没有 cookie API)。所以「登录后导出 cookie、下次注入恢复」这条路在渲染进程里走不通。
111
+
112
+ 更关键的是:**DeepSeek 的登录态根本不在 cookie 里**,而在 IndexedDB / localStorage;因此唯一正确的解法是**让分区落盘**。
113
+
114
+ ### 5.2 壳补丁(`tools/shell-patch/`)
115
+
116
+ `patch-asar.cjs` 改 `resources/app.asar` 里 `lib/main.js` 的两处:
117
+
118
+ ```js
119
+ // 1) 稳定、持久的分区名
120
+ partition = `persist:dsh-sidebar-${createHash('sha256').update(workspace).digest('hex').slice(0, 24)}`
121
+
122
+ // 2) 关掉磁盘缓存(登录态不受影响)
123
+ this.configureSession(session.fromPartition(partition, { cache: false }))
124
+ ```
125
+
126
+ 效果与代价:
127
+
128
+ | 项 | 结果 |
129
+ | --- | --- |
130
+ | 分区位置 | `<userData>/Partitions/dsh-sidebar-<sha256(workspace)[0..24]>/` |
131
+ | 跨重启登录 | ✅ 首次重启需登录一次,之后免登录 |
132
+ | 单次启动写入 | ≈13 MB → ≈0.09 MB(`Cache` 零新增,`Code Cache` 约 0.02 MB) |
133
+ | 副作用 | 壳自带右侧栏浏览器也变成持久分区 |
134
+ | 升级风险 | 客户端升级会覆盖 `app.asar`,**必须重打**;`restore-shell-patch.cmd` 可还原 |
135
+
136
+ > 补丁丢失时插件行为不变,只是回到"每次重启重新登录"。
137
+ >
138
+ > 插件侧只能**检测**补丁是否存在:guest 拿到的分区以 `persist:` 开头即已开启,否则是内存分区;结果记进 `localStorage['dsh-chat-embed:persist']`,设置菜单在重启后立即显示该状态。壳在 `acquire(workspace)` 里只接收 workspace,分区名由主进程决定,渲染进程没有任何接口能在运行时切换,所以菜单里只有状态与「复制补丁说明」,没有可点的开关。
139
+
140
+ ---
141
+
142
+ ## 6. 导航:只有一条路
143
+
144
+ guest attach 后停在 `about:blank#<lease>`。实测/契约:
145
+
146
+ | 方式 | 结果 |
147
+ | --- | --- |
148
+ | `el.loadURL(url)` | ❌ 静默无效 |
149
+ | `el.setAttribute('src', url)` | ❌ 静默无效 |
150
+ | `el.executeJavaScript('location.href = …')` | ✅ 文档级导航,触发 `will-frame-navigate` |
151
+
152
+ 并且**第一次 `dom-ready` 可能早于壳完成租约绑定**,所以 `navigateGuest()` 采用「最多 5 次、每次间隔 350ms、以 `getURL()` 是否到达目标源为准」的重试模型。到达目标源(含 `/sign_in`)即视为 `ready`。
153
+
154
+ 导航守卫:壳的 `will-frame-navigate` 只允许 `http(s)`、无 userinfo、且不是 dsh Host 自身 —— 各服务站点天然在允许范围内。
155
+
156
+ ---
157
+
158
+ ## 7. UA 归一化
159
+
160
+ 默认 Electron UA 含 `Electron/<ver>` 与 `dsh-desktop/<ver>`,`chat.deepseek.com` 会叠加「使用环境异常」风控弹窗。
161
+
162
+ 实现上不写死 Chrome 版本,而是**运行时从 `navigator.userAgent` 里过滤掉 `electron/`、`dsh-desktop/`、`deepseek-harness/` 三个 token**,保留其余部分;过滤后为空时退回内置的 Chrome UA。这样壳升级换 Chromium 版本时不用改插件。
163
+
164
+ ---
165
+
166
+ ## 8. Slot 契约要点
167
+
168
+ | 事实 | 说明 |
169
+ | --- | --- |
170
+ | `ctx.slots.register(options, Component)` | 两参数;目标 slot 在 `options.name`;不是三参数 |
171
+ | list slot 必须有 `id` | `sidebar.panellist` / `conversation.session.header.utilities` 属 list |
172
+ | keyed slot 必须有 `key` | `main`、`sidebar.right.pane.tab` 属 keyed |
173
+ | 注册别人的 slot 必须 `ctx.slots.inject(key, cb)` | 声明未就绪时 `register` 会抛错;`inject` 会等声明出现再执行、声明消失自动回收 |
174
+ | `registrant` 可选 | 写进去之后 `cordis_inspect_query(Slots)` 的 `occupants` 会显示插件名,便于排查 |
175
+ | `locale: NS` | 声明后组件 props 多一个 `t()`;`label` 传 thunk 可跟随语言切换而不重注册 |
176
+
177
+ 本插件的五处注册:
178
+
179
+ ```js
180
+ // 中央主面板:聊天页本身
181
+ ctx.slots.inject('main', () => ctx.slots.register(
182
+ { name: 'main', key: 'chat-embed', locale: NS, registrant: 'dsh-chat-embed' }, ChatCenterPage))
183
+
184
+ // 左侧栏图标
185
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
186
+ { name: 'sidebar.panellist', id: 'chat-embed', order: 30, label: () => t('navLabel'),
187
+ locale: NS, registrant: 'dsh-chat-embed' }, ChatNavIcon))
188
+
189
+ // 右栏聊天 tab:tab 类型走 sidebarRightTabs 服务,body 与 chip 标题走 slot
190
+ ctx.get('sidebarRightTabs').register(rightTabType(t('rightTabTitle'))) // id: 'dsh-chat-embed', kind: 'chat-embed'
191
+ ctx.slots.inject('sidebar.right.pane.tab', ...) // ChatRightBody
192
+ ctx.slots.inject('sidebar.right.pane.tab.title', ...) // ChatRightTitle
193
+
194
+ // 会话标题栏右侧的「⧉」按钮(list slot)
195
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register(
196
+ { name: 'conversation.session.header.utilities', id: 'chat-embed-dock', order: 60,
197
+ locale: NS, registrant: 'dsh-chat-embed' }, ChatDockButton))
198
+ ```
199
+
200
+ **不需要**在插件 `inject` 里写客户端服务名之外的东西:`inject: ['slots', 'locale']` 即可;跨模块依赖写在 `package.json` 的 `dsh.client.inject`。
201
+
202
+ ---
203
+
204
+ ## 9. 多标签模型:一服务一 guest、一分区
205
+
206
+ ### 9.1 数据模型
207
+
208
+ `PROVIDERS` 是唯一需要维护的清单,每条记录五项:
209
+
210
+ | 字段 | 作用 | 约束 |
211
+ | --- | --- | --- |
212
+ | `id` | 标签 id,同时用于 `data-provider` 与分区命名 | `^[a-z0-9-]+$`,全局唯一 |
213
+ | `label` | 标签文字 | 非空 |
214
+ | `url` | 首次打开时导航到哪 | `https://` |
215
+ | `origin` | 站点基址(用于诊断与后续策略) | 必须是 `url` 的前缀 |
216
+ | `workspace` | 壳的**存储身份** = 分区键 | 全局唯一,且**一旦发布不可改**(改了就退出登录) |
217
+
218
+ 门禁(`scripts/verify-bundle.mjs`)会检查唯一性、`https`、以及"运行时只允许出现注册表里声明过的外部站点",防止漏配或偷偷请求别处。
219
+
220
+ ### 9.2 一服务一 guest
221
+
222
+ ```text
223
+ guests: Map<providerId, {
224
+ lease, partition, mounts, mountsThisRun,
225
+ layer, element, status, lastUrl, handoff, oauthBlocked,
226
+ detachTimer, recoveryTimer, diagOpen, selfTest, lastEvent,
227
+ }>
228
+ ```
229
+
230
+ - `element` 是 `<webview>`;`layer` 是包住它的 `.dce-guest-layer`。
231
+ - **只有当前激活的标签可见**(`data-active="true"`),其它层 `display:none` —— 元素不移动,所以草稿、滚动位置、会话都保留。
232
+ - `lastUrl` 记录 guest 最后停留的可用地址,供席位切换后 `startUrlFor()` 恢复。
233
+
234
+ ### 9.3 内存与取舍
235
+
236
+ 每个点亮的标签 = 一个完整 webview = 一个渲染进程。默认只点亮当前标签;7 个全开会明显吃内存。`⚙ 设置` 可以整体禁用某些服务(至少保留一个),禁用时释放其租约。
237
+
238
+ ### 9.4 `keepMounted` 与右栏
239
+
240
+ 右栏 tab 类型声明 `keepMounted: true`:切到别的右栏 tab 时 body 不卸载,guest 与其状态保留 —— 与壳自带浏览器、终端面板同机制。中央面板没有这个待遇(选别的主面板就卸载),这是两个席位的本质差异。
241
+
242
+ ---
243
+
244
+ ## 10. 席位模型:内联宿主与"哪边点哪边接管"
245
+
246
+ ### 10.1 为什么必须内联
247
+
248
+ **Electron 在 `<webview>` 离开 DOM 时立即销毁其 WebContents**:重新插入等于重建,且无法"搬家"(搬 = detach + attach)。所以聊天页不能"先放别处、需要时挪进面板",只能**渲染在席位自己的布局盒里**:
249
+
250
+ ```text
251
+ .dce-panel.dce-inline position:relative; width:100%; height:100%; overflow:hidden
252
+ .dce-surface position:absolute; inset:0; display:flex; flex-direction:column
253
+ .dce-tabs 标签条(可拖动排序)
254
+ .dce-status-bar 状态行(仅在加载中/失败时出现)
255
+ .dce-viewport 宿主视口(flex:1)—— 注册为 inlineHosts[seat]
256
+ .dce-guest-layer 每个服务一层;非激活层 display:none
257
+ ```
258
+
259
+ 拖分隔条时浏览器做一次原生布局,**零 JS 测量、零 React 重渲染**。`dragProbe` 实测 `397 → 445 → 397`,`guestFollowed=true`。
260
+
261
+ ### 10.2 两个席位
262
+
263
+ | 席位 | 注册位置 | 生命周期 | 承载条件 |
264
+ | --- | --- | --- | --- |
265
+ | `main` | `main` slot(`key: 'chat-embed'`) | 选中该面板时挂载,切走即卸载 | 用户点左栏入口 |
266
+ | `right` | 右栏 tab body | 右栏打开且 tab 选中;`keepMounted` | 用户点右栏「聊天」tab |
267
+
268
+ 二者互斥:右栏属于**会话视图**,中央换成聊天面板后右栏会报 `no session surface is mounted`。
269
+
270
+ ### 10.3 接管规则
271
+
272
+ ```text
273
+ preferredSeat 用户最近一次"点开"的席位(挂载时写入;右栏 tab 被选中时也写入)
274
+ inlineHosts[seat] 该席位已挂载的宿主视口
275
+ inlineSeat() 按 [首选, 另一席位] 顺序挑第一个"可用"的席位
276
+ hostArea(host) 宿主是否真的有面积(0×0 的收起右栏不算可用)
277
+ ```
278
+
279
+ 两个细节决定了实际体验:
280
+
281
+ 1. **首选席位在注册前也算宿主**。否则会出现死锁:面板挂载时想认领 guest,但"认领"要求宿主已注册,而注册只发生在"开始承载"之后。
282
+ 2. **零尺寸只允许中央面板**。刚挂载的中央面板有一帧是 0×0(正常),而收起的右栏永远是 0×0 —— 把页面搬进去就等于白屏。右栏展开时由 `ResizeObserver` 重新判定一次席位。
283
+
284
+ ### 10.4 切换的代价与恢复
285
+
286
+ 席位变化 → 旧宿主里的 guest 元素随 DOM 销毁 → `dropGuestsOutside()` 丢弃记录 → 在新宿主 `mountGuest()` 重建(新租约、同分区)→ `startUrlFor()` 用 `lastUrl` 回到上次会话。
287
+
288
+ - 只需一次页面加载,**不需要重新登录**(分区不变);
289
+ - 同源且地址仍可用才复用 `lastUrl`,否则回到服务入口;
290
+ - 迁移失败(例如站点把会话 URL 改成一次性)时退回入口,行为与首次打开一致。
291
+
292
+ ---
293
+
294
+ ## 11. 每个标签的生命周期
295
+
296
+ ```text
297
+ idle → acquiring → navigating → ready
298
+ ↘ ↘
299
+ failed ← (retry acquires a *fresh* lease)
300
+ ```
301
+
302
+ - `acquiring`:`browser.acquire(workspace)` 申请租约;
303
+ - `navigating`:创建 `<webview>` 并从文档内部导航;
304
+ - `ready`:到达目标源;
305
+ - `failed`:租约被拒、导航超时、崩溃 —— 只影响该标签,其它标签不受影响。
306
+
307
+ ### 11.1 OAuth 与弹窗
308
+
309
+ 壳的 `disablePopups` 让我们无法真正开弹窗(`allowpopups` 仍在元素上,但主进程禁弹窗),所以:
310
+
311
+ - 站点用 `window.open` 打开的登录页:插件捕获 `new-window` 事件,把 URL **在同一 guest 里继续**(`navigateInPlace`);
312
+ - Google 的 `gsi/transform` 交回页是**死路**(没有 `window.opener`,凭据无法交回):检测到后 `record.oauthBlocked = true`,自动退回站点入口并显示说明卡片;用户可用邮箱登录;
313
+ - 恢复期间用 `TIMINGS` 里的四个延迟(落地、空窗观察、凭据探测、恢复重载)控制节奏,不轮询。
314
+
315
+ ---
316
+
317
+ ## 12. 诊断与探针
318
+
319
+ `⚙` 菜单里的「标签页诊断…」卡片与 Inspect Provider 共用一套读数:
320
+
321
+ | 探针/字段 | 内容 |
322
+ | --- | --- |
323
+ | `seatReport` | `build` / `mode: 'inline'` / `seat` / `host`(席位宿主矩形)/ `guest`(present、webviews、layers、active、hostBox)/ `right`(右栏注册状态)/ `selfTest` |
324
+ | `dom` | `.dce-panel.dce-inline` 数量、`webview` 数量、工具行按钮数、日志开关、持久化状态、各席位是否已注册、当前首选与宿主席位 |
325
+ | `layout` | `layout` 服务的键、`hasMainPanel('chat-embed')`、左栏导航 DOM 条目 |
326
+ | `log` | 调试日志开关与最近 50 条环形缓冲;`{ enabled: true/false }` 开关,`{ clear: true }` 清空。默认关闭,关闭时不打印也不缓冲 |
327
+ | `locale` | 当前活跃语言(`active`)、可用语言列表、`<html lang>`,以及面板文案当前解析出的样例(`sample` / `zhSample` / `enSample`) |
328
+ | `openPanel` | 选中中央面板(等价于点左栏图标;探针路径不等于真实点击,见下) |
329
+ | `openRight` | 展开右栏并打开聊天 tab,报告每一步的错误 |
330
+ | `prefer` | 把席位偏好切到 `main` / `right`,返回解析后的席位与宿主 |
331
+ | `handles` | 右栏注册状态与各 seat 的尝试结果 |
332
+ | `dragProbe` | 用合成指针拖动右栏分隔条再复位,报告宿主与 guest 宽度是否跟随 |
333
+
334
+ > 注意:`openPanel` 调用的是 `layout.selectPanel()`,走的是服务层状态;真实点击走侧栏面板列表自己的 store 路径。两者渲染时机不一定一致,**判断用户视角是否正常要以真实点击为准**。
335
+
336
+ ### 12.1 调试日志(默认关闭)
337
+
338
+ 日志分三处消费,开关只有一个:
339
+
340
+ | 出口 | 内容 |
341
+ | --- | --- |
342
+ | 控制台 | `[dsh-chat-embed] <ISO 时间> <级别> <事件>` + 结构化 detail |
343
+ | 环形缓冲 | 最近 200 条;菜单「调试日志」一行的「复制日志」导出完整 JSON |
344
+ | Inspect 探针 | `log`:读开关与最近 50 条;`{ enabled: boolean }` 开关,`{ clear: true }` 清空 |
345
+
346
+ 默认关闭(`localStorage['dsh-chat-embed:log']` 不存在或不为 `1`):不打印、不缓冲,零开销。开关入口在「⚙」菜单(工具行只保留重新加载 / 外开 / 设置);状态持久化,下次启动沿用。
347
+
348
+ 记录点覆盖:租约获取、重新加载、加载失败、渲染进程崩溃、席位偏好 / 宿主解析、右栏 slot 注册失败、服务启停。不记录用户输入、URL 凭据或页面内容。
349
+ ---
350
+
351
+ ## 13. 升级桌面壳时的复验清单
352
+
353
+ 按顺序跑,前一项失败就不要继续:
354
+
355
+ 1. `main` 窗口仍 `webviewTag: primary`;
356
+ 2. `DesktopBrowserGuests.bind()` 的校验仍是 `src === about:blank#<lease>` + `partition` + 未使用;
357
+ 3. `acquire()` 返回的 partition 仍是**内存**分区(或补丁重打成功,出现 `persist:`);
358
+ 4. `preload-app.cjs` 仍暴露 `window.dshDesktop.browser.acquire / release / onOpenRequested`;
359
+ 5. `main` / `sidebar.panellist` / `sidebar.right.pane.tab` / `conversation.session.header.utilities` 四个 slot 的注册选项(`key` / `id` / `order` / `label`)是否变化;
360
+ 6. `will-frame-navigate` 的允许范围是否仍包含各服务站点;
361
+ 7. 右栏 `sidebarRight.openTab()` 在会话视图里可用、在聊天面板打开时**不可用**(互斥是预期行为);
362
+ 8. `seatReport`:左栏入口挂载后 `seat=main`、`guest.present=true`、`host` 尺寸等于中央区域;
363
+ 9. `dragProbe`:`guestFollowed=true`,拖动前后宽度变化一致;
364
+ 10. 重启应用后 `build` 正确、右栏 tab 类型注册成功、左栏入口可点开。
365
+
366
+ ---
367
+
368
+ ## 14. 扩展点(与 README 路线图对应)
369
+
370
+ | 扩展 | 做法 | 当前状态 |
371
+ | --- | --- | --- |
372
+ | 多壳适配 | 抽象 `desktopBrowser(scope)`,为 Tauri / 自定义原生协议实现同名接口 | 仅 Electron |
373
+ | 可关闭标签 | 关闭时 `releaseLease` 并删除 layer;顺序表同步 | 未做(现在只能 `⚙` 禁用) |
374
+ | 服务列表可配置 | 把 `PROVIDERS` 迁到 Host `Config`,客户端读 ctx 配置 | 未做(现在改代码 + 重跑门禁) |
375
+ | 内存策略 | 记录最后使用时间,LRU 回收长时间未用的 guest,切回重建 | 未做 |
376
+ | 模态让位 | 检测 `role="dialog"[aria-modal]` 时临时隐藏聊天页 | 未做(Electron 下 DOM 弹窗本就压得住 webview) |
@@ -0,0 +1,78 @@
1
+ # 发布与分发(维护者)
2
+
3
+ 面向维护者的发版流程。用户安装请看 [README](../README.md#快速开始)。
4
+
5
+ ## 版本与提交约定
6
+
7
+ - 同时改 `package.json` 的 `version` 与 `client.js` 的 `BUILD`,两者必须一致;
8
+ - 门禁必须绿:`npm run check`(`verify-bundle` + 62 项契约测试);
9
+ - 代码注释/提交信息英文,文档中文。
10
+
11
+ ## 发版步骤
12
+
13
+ ```powershell
14
+ npm run check # 门禁:verify-bundle + 62 项测试(prepublishOnly 也会跑)
15
+ npm run pack:dist # 产出 dist\dsh-chat-embed-<版本>.tgz
16
+ gh release create v0.5.0 dist\dsh-chat-embed-0.5.0.tgz --title "v0.5.0" --notes-file dist\release-notes.md
17
+ ```
18
+
19
+ `dist/` 与 `*.tgz` 已被 `.gitignore` 忽略;分发包只走 GitHub Release 资产。
20
+
21
+ 发版后自检:
22
+
23
+ ```powershell
24
+ gh release download v0.5.0 --pattern '*.tgz' --dir $env:TEMP\check
25
+ Get-FileHash $env:TEMP\check\dsh-chat-embed-0.5.0.tgz -Algorithm SHA256
26
+ ```
27
+
28
+ ## 当前发布物
29
+
30
+ | 项 | 地址 |
31
+ | --- | --- |
32
+ | 仓库 | <https://github.com/poluce/dsh-chat-embed> |
33
+ | Release | <https://github.com/poluce/dsh-chat-embed/releases> |
34
+ | 分发包 | `dsh-chat-embed-0.5.0.tgz`(15 个文件,约 60 kB) |
35
+ | 商店图标 | `assets/icon-180.png`(180×180 PNG) |
36
+ | 商店截图 | `assets/screenshots/` + 仓库根 `screenshots.json`(6 张) |
37
+
38
+ ## 分发渠道
39
+
40
+ ### 路线 A:不注册 npm(GitHub Releases / 直接给 tgz)
41
+
42
+ ```powershell
43
+ dsh plugin --profile desktop add .\dsh-chat-embed-0.5.0.tgz # 本地文件(已实测)
44
+ # 或: dsh plugin --profile desktop add <https://.../dsh-chat-embed-0.5.0.tgz>
45
+ # 或直接让别人从公开仓库装:
46
+ dsh plugin --profile desktop add git+https://github.com/poluce/dsh-chat-embed.git
47
+ ```
48
+
49
+ ### 路线 B:发布到 npm(可选)
50
+
51
+ 1. 注册账号并完成邮箱验证:<https://www.npmjs.com/signup>;
52
+ 2. 本机登录:`npm login`(浏览器确认或一次性密码;开了 2FA 输入验证码);
53
+ 3. 发布:`npm publish`(`prepublishOnly` 自动跑门禁;包名 `dsh-chat-embed` 已确认可用);
54
+ 4. 使用者随后只需:`dsh plugin --profile desktop add dsh-chat-embed`。
55
+
56
+ ## 发布前后检查项
57
+
58
+ - `engines.dsh` 与实测 dsh 版本一致(当前实测 `dsh --version` = `0.1.0-rc.6`);壳升级改掉 slot / 服务名时要同步升版本;
59
+ - 改包名(例如加 npm 作用域)必须同步改 `cordis.patch.yml` 里 loader 行的 `name`(必须等于包名);`id` 保持唯一即可;
60
+ - `repository` / `homepage` / `bugs` 当前指向 `github.com/poluce/dsh-chat-embed`;换账号或换仓库时改这三处;
61
+ - 发布后用一份干净 profile 验证:`dsh plugin --profile <新名字> add <包名>`,然后打开左栏入口;
62
+ - 可选壳补丁(跨重启免登录)不是安装必需项;不装补丁时功能完整,只是重启后要重新登录。
63
+
64
+ ## 提交到插件列表(awesome-dsh-plugin)
65
+
66
+ - 列表只收一个条目文件 `data/plugins/<owner>__<repo>.yml`,README 由脚本生成,不要手改;
67
+ - 前置条件:`dsh.bundle` manifest、真实代码、仓库创建满 1 天、带 `dsh-plugin` topic、位图图标(SVG 不收);
68
+ - 我们的条目:`category: ui`,`icon` 指向 `assets/icon-180.png`,`tarball` 指向 Release 资产;
69
+ - 投稿 PR:<https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/13872>(仓库满 1 天后自动通过年龄检查)。
70
+
71
+ ## 截图维护
72
+
73
+ `assets/screenshots/` 里是 1600×1027 的 PNG(由原始 2564×1646 截图缩放而来),顺序即 `screenshots.json` 的顺序:
74
+
75
+ 1. `01-deepseek-left-panel.png` / `02-chatgpt-left-panel.png` / `03-gemini-left-panel.png` / `04-grok-left-panel.png` —— 左栏中央面板;
76
+ 2. `05-deepseek-right-tab.png` / `06-gemini-right-tab.png` —— 右栏聊天 tab。
77
+
78
+ 界面改动较大时重新截图,保持同样的尺寸与命名即可;README 与商店详情页用的是同一批文件。
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Chat embed">
2
+ <defs>
3
+ <linearGradient id="dce-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#4D6BFE"/>
5
+ <stop offset="1" stop-color="#2F45C5"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="url(#dce-bg)"/>
9
+ <path d="M18 20.5A4.5 4.5 0 0 1 22.5 16H45a4 4 0 0 1 4 4v13a4 4 0 0 1-4 4H30.5L21 44.5V37a4.5 4.5 0 0 1-3-4.24V20.5Z"
10
+ fill="#FFFFFF" fill-opacity="0.14" stroke="#FFFFFF" stroke-width="2.2" stroke-linejoin="round"/>
11
+ <path d="M25.5 24.5h15M25.5 30.5h9" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round"/>
12
+ <path d="M46 45.5v-4M46 52.5v-4M42.5 49h4M49.5 49h4" stroke="#FFFFFF" stroke-opacity="0.75"
13
+ stroke-width="2" stroke-linecap="round" transform="translate(-1 0)"/>
14
+ </svg>
package/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * dsh-chat-embed — Host half.
3
+ *
4
+ * The whole feature lives in the Client half (`client.js`): the desktop
5
+ * webview, the lease handshake, the panel UI. This half only has to exist so
6
+ * the loader row named by `cordis.patch.yml` has a Node-side export to mount;
7
+ * `dsh.client` in `package.json` publishes the browser half beside it.
8
+ *
9
+ * Keep this module inert. It must not import browser globals, and anything it
10
+ * registers must be cleaned up through `ctx.effect` so uninstalling the bundle
11
+ * leaves the Host process exactly as it was.
12
+ */
13
+
14
+ export const name = 'dsh-chat-embed'
15
+
16
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
17
+ export function apply(ctx) {
18
+ // Nothing to bind on the Host side yet.
19
+ //
20
+ // Extension point: if a later version needs to persist settings (chat URL,
21
+ // user-agent mode), declare `export const Config` here and read it in the
22
+ // Client half through its own `dsh.client` declaration. Secrets belong to
23
+ // `ctx.credentials`, never to this plugin's configuration.
24
+ void ctx
25
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Chat embed",
4
+ "description": "Opens a multi-tab AI chat panel in the desktop app: DeepSeek, Doubao, ChatGPT, MiniMax, Kimi, Gemini and Grok, each signed in separately."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "内嵌聊天",
4
+ "description": "在桌面版打开多标签 AI 聊天面板:DeepSeek、豆包、ChatGPT、MiniMax、Kimi、Gemini、Grok,各自独立登录态。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "dsh-chat-embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.4",
4
+ "author": "poluce <fishsummer@126.com>",
5
+ "description": "Multi-tab AI chat embed for the DeepSeek Harness desktop app: DeepSeek, 豆包, ChatGPT, MiniMax, Kimi, Gemini and Grok, each signed in separately.",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "client.js",
17
+ "cordis.patch.yml",
18
+ "locale/*.json",
19
+ "icon.svg",
20
+ "docs",
21
+ "tools/shell-patch",
22
+ "restore-shell-patch.cmd",
23
+ "清理聊天缓存.cmd",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true,
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-layout",
37
+ "@deepseek-ai/dsh-client-ui-sidebar"
38
+ ]
39
+ }
40
+ },
41
+ "scripts": {
42
+ "check": "node scripts/verify-bundle.mjs && npm test",
43
+ "test": "node --test \"test/**/*.test.mjs\"",
44
+ "prepublishOnly": "npm run check",
45
+ "pack:dist": "node scripts/pack-dist.mjs"
46
+ },
47
+ "keywords": [
48
+ "dsh",
49
+ "deepseek-harness",
50
+ "dsh-plugin",
51
+ "cordis",
52
+ "chat",
53
+ "webview",
54
+ "embed"
55
+ ],
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/poluce/dsh-chat-embed.git"
59
+ },
60
+ "homepage": "https://github.com/poluce/dsh-chat-embed#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/poluce/dsh-chat-embed/issues"
63
+ },
64
+ "license": "MIT",
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "engines": {
69
+ "node": ">=22",
70
+ "dsh": ">=0.1.0-rc.6"
71
+ }
72
+ }
@@ -0,0 +1,39 @@
1
+ @echo off
2
+ rem Restore the original DeepSeek Harness app.asar after the persistence patch.
3
+ rem
4
+ rem Usage: restore-shell-patch.cmd [resources-dir]
5
+ rem The directory may also come from DSH_RESOURCES, otherwise the usual install
6
+ rem locations are tried.
7
+ setlocal
8
+ if not "%~1"=="" set "RES=%~1"
9
+ if "%RES%"=="" set "RES=%DSH_RESOURCES%"
10
+ if "%RES%"=="" if exist "%LOCALAPPDATA%\Programs\DeepSeek Harness\resources\app.asar" set "RES=%LOCALAPPDATA%\Programs\DeepSeek Harness\resources"
11
+ if "%RES%"=="" if exist "C:\Program Files\DeepSeek Harness\resources\app.asar" set "RES=C:\Program Files\DeepSeek Harness\resources"
12
+ if "%RES%"=="" goto nores
13
+ if not exist "%RES%\app.asar.orig" goto nobackup
14
+
15
+ if exist "%RES%\app.asar" (
16
+ move /Y "%RES%\app.asar" "%RES%\app.asar.patched.bak" >nul
17
+ )
18
+ move /Y "%RES%\app.asar.orig" "%RES%\app.asar" >nul
19
+ echo.
20
+ echo [OK] 已恢复原版 app.asar
21
+ echo 补丁版备份在 app.asar.patched.bak
22
+ echo.
23
+ goto end
24
+
25
+ :nores
26
+ echo.
27
+ echo [!!] 找不到 DSH 安装目录:请传入参数,或设置 DSH_RESOURCES。
28
+ echo 例:restore-shell-patch.cmd "D:\DeepSeek Harness\resources"
29
+ echo.
30
+
31
+ goto end
32
+
33
+ :nobackup
34
+ echo.
35
+ echo [!!] 找不到 app.asar.orig,可能从未打过补丁,或已被还原过。
36
+ echo.
37
+
38
+ :end
39
+ pause