dsh-model-groups 0.3.2 → 0.3.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.
package/README.md CHANGED
@@ -1,127 +1,126 @@
1
- # dsh-model-groups 0.3.2(0.2.2-rc.1 移植版)
1
+ # DSH Model Groups
2
2
 
3
- 在 DSH 原生「自定义模型 API」卡片内管理**同一个供应商的多个密钥配置**,并把会话模型菜单显示为「供应商 → 密钥配置 → 模型」。这不是密钥轮换器,也不是另建一页大、小分组配置。
3
+ 为 DeepSeek Harness 网页端提供分层模型菜单与多密钥管理。同一供应商共用 Provider ID、API 地址和协议,各配置分别设置名称、密钥与模型列表。
4
4
 
5
- ## 使用方式
5
+ ## 功能
6
6
 
7
- 1. 打开 **设置 → 模型**,新增或编辑原生的**自定义模型 API**。
8
- 2. Provider ID、API 地址、协议只填写一次,所有密钥配置共享这些设置。
9
- 3. 打开卡片内的 **多密钥配置** 开关,按需点击 **添加密钥配置**。每个配置分别填写配置名称、API 密钥、模型列表,然后应用。
10
- 4. 在会话模型菜单中展开供应商,再展开需要的密钥配置,选择模型。
7
+ - 按“供应商 → 密钥配置 → 模型”浏览和选择。
8
+ - 支持原地折叠、侧边面板、名称搜索、全部展开与收起。
9
+ - 记住各级折叠状态;搜索时临时展开匹配项。
10
+ - 不同密钥配置可使用相同模型 ID,选择时使用对应配置的密钥。
11
+ - 手动选择配置,不自动轮换密钥或切换到其他配置。
11
12
 
12
- ```text
13
- 示例供应商 A(同一个 Provider ID / API 地址)
14
- 配置 A(独立密钥)
15
- 示例模型
16
- 配置 B(另一份密钥)
17
- 示例模型
18
- 示例供应商 B
19
- 示例模型
20
- ```
13
+ ## 环境要求
21
14
 
22
- - **未开启多密钥配置的供应商保留原来的折叠方式**,不会平铺全部模型。
23
- - 供应商和密钥配置独立折叠;收起父级不清除子级折叠状态。
24
- - 配置数量按需增删,不设置固定的数量上限。名称去除首尾空格,长度为 1–120 个字符;保存时每个配置至少包含一个模型。
25
- - 相同模型 ID 可以同时出现在不同配置中;选择保留精确配置身份,不因同名而合并。
26
- - 只使用手动选中的配置。密钥缺失、配置移除或多密钥配置关闭时,旧配置选择会失败或从菜单移除,**不会改用其他密钥、轮换或自动故障切换**。
27
- - 保存前可用输入的 API 密钥获取模型;已保存的配置按其精确标识在宿主查询。不会默默改用供应商原来的密钥。
28
- - API 密钥使用原生的只写输入与凭据服务,不会回显已保存的明文。留空保留原凭据;替换共享引用的密钥需要明确确认。
15
+ - DeepSeek Harness **0.2.2-rc.1**。
16
+ - Node.js **22 或以上**。
17
+ - Harness 网页界面;在 dsh-go 中使用时,功能出现在对应网页,不影响 App 原生模型菜单。
18
+ - DSH 安装目录可写,且目标程序文件符合支持的版本与哈希。
29
19
 
30
- ### 界面与关闭保护
20
+ **本插件包含宿主兼容补丁。** 插件加载时会检查并备份原文件,自动应用五项补丁;不修改现有供应商、密钥或会话配置。版本或文件内容不受支持时会停止应用,不强制覆盖。
31
21
 
32
- - 「多密钥配置」使用原版开关组件与表单字体;标题与开关左右对齐。
33
- - 配置名称位于卡片内部的标题栏,移除按钮在标题栏右侧;不再使用挂在边框上的图例标题。长名称与凭据引用允许换行。
34
- - 支持多密钥的已有自定义供应商先显示「公共设置」,再显示密钥配置;其他供应商保持原有表单顺序与折叠状态。
35
- - 点击关闭不会立即停用,先显示原生确认弹窗。取消、关闭按钮、Escape 或遮罩关闭只取消确认,不会改变开关或丢弃输入;确认后仍须点击「应用」才改变运行配置。
36
- - **有尚未保存的 API 密钥输入时禁止确认关闭**,只提供「返回密钥配置」。输入仅为空格也触发保护;凭据保存失败留下的输入同样受保护。先保存成功,或明确清空不需要的输入,再关闭。
22
+ ## 安装
37
23
 
38
- ## 兼容与数据保全
24
+ 在目标设备的 DSH 环境中执行:
39
25
 
40
- 开启已有供应商的多密钥配置时,首个配置从原单密钥配置复制**密钥引用和模型配置**,不读取密钥值。原单密钥配置仍保留,用于关闭多密钥配置及已有旧会话。
26
+ ```bash
27
+ dsh plugin --profile web add dsh-model-groups@0.3.4 --registry=https://registry.npmjs.org/
28
+ ```
41
29
 
42
- 旧会话中未带配置标识的模型继续使用原来的密钥,不会按同名模型猜测新配置。会话当前仍选用旧模型时,菜单会显示「原单密钥(兼容旧会话)」子项供辨认;其他旧模型不会混入多密钥配置的可选项。切换到新配置需要主动选择。
30
+ 安装包:[npm](https://www.npmjs.com/package/dsh-model-groups) · [GitHub 发布页](https://github.com/betsunninda9-lgtm/dsh-model-groups/releases/tag/v0.3.4)。`web` 为 dsh-go 本机常用 profile,其他环境请使用实际 profile。
43
31
 
44
- 关闭并应用会保留所有已保存的密钥配置与凭据,并恢复原单密钥模式;重新开启并应用后可再次使用这些配置。正在使用密钥配置的会话需要重新开启,或手动选择单密钥模型,不会自动换用其他密钥。移除配置也不删除已存储的凭据;需要清理时请单独使用原生凭据管理。
32
+ 插件加载并提示“补丁已自动安装”后,保存草稿,**重启原 DSH 引擎并刷新网页**。无需先停止引擎,也不需要外部终端手工打补丁。
45
33
 
46
- **不会自动合并以前分别建立的供应商。** 特别是 API 地址不同的渠道不能直接合并;迁移须由使用者确认共享地址、密钥和模型归属后手动完成。此次实现不改既有真实密钥、接口地址或模型列表。
34
+ 如果安装后插件尚未加载,先启动或重启引擎;若此次启动才应用补丁,再重启一次以载入新模块。补丁已在磁盘就绪时,后续加载不会重复写入。
47
35
 
48
- 上一版独立的「模型分组」页脚与 `providerGroups` 显示配置已撤回;本版直接使用原生 `llm-pi-ai.providers` 下的 `multiKey`、`keyEntries`。折叠状态继续使用本机 `localStorage` 的 `dsh:model-groups:v1`;原 Provider ID 的记录仍然有效,密钥配置子项使用独立键。
36
+ 当前正式版为 `0.3.4`,包含已验证的自动补丁安装与侧边面板修复。
49
37
 
50
- ## 搜索与键盘
38
+ ### 交给 AI 安装
51
39
 
52
- - 搜索支持供应商、密钥配置及模型名称;搜索时临时展开匹配路径,清空后恢复原有折叠状态。
53
- - 全部展开 / 全部收起同时作用于供应商和密钥配置。
54
- - `↑` / `↓` 在工具条、标题和可见模型间移动;标题上的 `←` / `→` 收起 / 展开,`Enter` / 空格切换。
55
- - 搜索框内 `↑` / `↓` 移动高亮,`Enter` / `Tab` 选择;`Esc` 返回上一层或关闭菜单。
56
- - 保留官方模型目录、推理等级、加载重试、标签插槽、锁定和错误提示。渲染异常时使用同样校验配置有效性的简化菜单。
40
+ ```text
41
+ 请按 https://github.com/betsunninda9-lgtm/dsh-model-groups 的安装说明,
42
+ 使用 npm 正式版安装到当前 DSH profile。
43
+ 我允许插件自动校验、备份并应用宿主补丁;保留现有配置。
44
+ 检查加载日志,成功后提醒我重启原引擎并刷新网页;遇到不兼容就停止报告。
45
+ ```
57
46
 
58
- ## 安装与生效边界
47
+ ## 使用
59
48
 
60
- 本版包括客户端插件与**原生兼容补丁**。仅安装插件不能使宿主识别同 Provider 的多个密钥;纯前端分组不能保证实际请求用对密钥。
49
+ 1. 打开 **设置 → 模型**,新增或编辑自定义模型 API。
50
+ 2. 填写供应商公共的 Provider ID、API 地址和协议。
51
+ 3. 开启 **多密钥配置**,为每份配置填写名称、密钥和模型列表,点击“应用”。
52
+ 4. 在会话模型菜单中展开供应商和配置,选择需要的模型。
61
53
 
62
- 原生补丁仅适配 **DSH 0.2.2-rc.1**,同时校验包名、版本和目标文件 SHA-256。它扩展原生 pi-ai 适配器、模型设置卡片,以及宿主 / 客户端 / 浏览器内嵌的模型发现编解码。补丁不复制凭据库,不创建重复的 Provider,不启动第二个服务。
54
+ ```text
55
+ 示例供应商 A
56
+ 配置 A
57
+ 示例模型
58
+ 配置 B
59
+ 示例模型
60
+ 示例供应商 B
61
+ 示例模型
62
+ ```
63
63
 
64
- 本插件用于 Harness Web 界面;在 dsh-go 中请打开对应的 Harness 网页界面使用。它不向 Flutter 原生模型选择器注入界面。
64
+ 已有单密钥配置与旧会话选择会保留。开启多密钥后,可在模型菜单手动选择新配置;插件不会自动合并已有供应商或迁移会话选择。
65
65
 
66
- 从 npm 获取可检查的插件目录(发布后可用):
66
+ 关闭多密钥配置会保留已保存的配置与凭据,删除配置也不会同时删除凭据。有尚未保存的密钥输入时,需要先保存或清空,才能确认关闭。
67
67
 
68
- ```bash
69
- npm pack dsh-model-groups@0.3.2 --registry=https://registry.npmjs.org
70
- mkdir dsh-model-groups
71
- tar -xzf dsh-model-groups-0.3.2.tgz -C dsh-model-groups --strip-components=1
72
- cd dsh-model-groups
73
- ```
68
+ 窄屏侧边面板会根据可用空间调整各级宽度;三级并列时,较长名称可能截断。
74
69
 
75
- 请保留该目录,安装会引用它。安装过程不会自动执行宿主补丁。
70
+ ## 更新
76
71
 
77
- ```bash
78
- # 在插件目录操作;路径必须是实际安装的 DSH 根目录
79
- node scripts/native-patches.mjs --dsh-root /实际安装的DSH根目录 --check
72
+ 使用对应发布版本的安装命令更新,随后按加载日志提示重启引擎并刷新网页。兼容补丁已经存在且未变化时,不会重复应用。
73
+
74
+ DSH 升级前请确认插件兼容范围。新版本或未知文件会被拒绝,需要等待对应适配,不能强制套用旧补丁。
75
+
76
+ DSHMarket 的收录需经过社区审核。普通插件的市场更新跟随 npm `latest`,不会自动跟随 GitHub 发布包或 npm `beta` 标签。
80
77
 
81
- # 空闲时先停止原 DSH 服务,再应用补丁
82
- node scripts/native-patches.mjs --dsh-root /实际安装的DSH根目录 --apply
78
+ ## 安装诊断
83
79
 
84
- # 安装客户端插件(已安装者无需重复添加)
85
- dsh plugin --profile web add /绝对路径/dsh-model-groups
80
+ 没有出现“多密钥配置”开关时,先确认原引擎已在自动补丁后重启,并刷新原网页。仍未恢复,可在已安装插件目录执行:
81
+
82
+ ```bash
83
+ node scripts/doctor.mjs --dsh-root /opt/dsh --json
86
84
  ```
87
85
 
88
- 检查模式只读;应用前先验证所有目标,拒绝未知版本或被其它修改覆盖的文件。原文件会备份,全部内容写入同目录临时文件成功后再逐个原子替换;后续失败会尽量回滚已替换文件,不覆盖并发的外部修改。重复应用为无操作。补丁脚本不会重启或热重载运行中的进程。
86
+ `/opt/dsh` 请替换为实际 DSH 安装目录。五项文件应为 `patched`。该命令只读检查程序文件,不读取密钥;磁盘状态正常不代表运行中的模块已刷新。
87
+
88
+ 自动安装日志位于原引擎日志中。遇到文件不受支持、目录布局不兼容或权限不足时,请提供程序包版本与文件 SHA-256,勿提供密钥。
89
89
 
90
- **首次应用宿主补丁后须重新启动原 DSH 服务,再刷新原有页面。** 只刷新页面、只重编客户端或反复开关插件,不能保证已加载的宿主模块和缓存的客户端元数据更新。不要另起服务器替代正在使用的 GUI。新开关也会检查宿主 schema:旧宿主尚未重新加载时保持原来的单密钥界面,不暴露无法保存的新配置。
90
+ 原文件备份保存在 `DSH_HOME/dsh-model-groups/<安装目录指纹>/backups`,不会随插件包更新丢失。安装意外中断可能留下 `patch.lock`;确认没有安装进程后再处理,不要并发重试。
91
91
 
92
- 后续只改模型设置客户端界面时,不代表必须重启宿主;但需核对原服务提供的实际客户端文件已更新,再刷新原页面。不要把磁盘生成成功或组件测试通过当成真实页面已经生效。刷新前先保存正在编辑的内容,避免丢失草稿。
92
+ ## 卸载与还原
93
93
 
94
- DSH 升级可能覆盖这些原生文件;不要强行把此版本补丁用于新版本,应先重新适配并验证。
94
+ 移除插件不会自动还原宿主补丁或删除凭据。完整还原前,请先:
95
95
 
96
- ### 卸载 / 回退
96
+ 1. 将相关会话和子代理切换回普通单密钥模型。
97
+ 2. 关闭多密钥配置,备份配置并清理原版 DSH 不支持的 `multiKey`、`keyEntries` 字段,保留仍需使用的凭据。
98
+ 3. 在独立维护目录保留插件包,确保卸载后仍能运行还原脚本。
97
99
 
98
- 先在相关会话中切换回原单密钥模型,并关闭各供应商的多密钥配置;使用者需确认所有保存的会话 / 子代理配置都不再选择 `@dsh-key/` 内部标识。随后空闲时停止原服务,再执行:
100
+ 先停用或移除插件,避免其再次加载时重新应用补丁:
99
101
 
100
102
  ```bash
101
- node scripts/native-patches.mjs --dsh-root /实际安装的DSH根目录 --revert
102
103
  dsh plugin --profile web remove dsh-model-groups
103
104
  ```
104
105
 
105
- 恢复原单密钥配置后,应在回退原生 schema 前移除不再受支持的 `multiKey`、`keyEntries` 字段(先备份配置,不删除原来的密钥与模型)。回退脚本仅恢复原生代码,不扫描或修改真实会话 / 配置 / 凭据,也不会自动证明回退前提已满足。仍选择内部配置标识的旧会话在原生补丁移除后无法发送请求。
106
+ 随后安排维护窗口,停止原服务,在保留的插件目录执行:
107
+
108
+ ```bash
109
+ node scripts/native-patches.mjs --dsh-root /opt/dsh --revert
110
+ ```
106
111
 
107
- ## 实现与验证
112
+ 完成后启动原服务并刷新网页。还原脚本按校验过的文件版本恢复代码,不修改会话或凭据。仍引用 `@dsh-key/` 模型的会话在还原后无法继续使用该选择。
108
113
 
109
- 内部模型标识为 `@dsh-key/<配置ID>/<编码后的上游模型ID>`,只用于保存选择身份;Provider ID 不变。适配器在请求边界解析它,使用独立配置的模型元数据和凭据引用,上游收到的仍是原始模型 ID。已准备请求保留当时的配置快照;跨密钥的签名历史不会直接复用到另一密钥。
114
+ ## 开发
110
115
 
111
116
  ```bash
112
117
  npm run build
113
118
  npm test
114
- npm run test:ui
115
119
  npm run check:build
116
- npm run native:check -- --dsh-root /实际安装的DSH根目录
117
120
  ```
118
121
 
119
- - [层级逻辑](src/hierarchy.js)、[会话选择器](src/model-select.js)、[回退菜单](src/fallback.js)和[样式](src/groups.css)由[构建脚本](scripts/build.mjs)生成客户端产物。
120
- - [原生补丁管理器](scripts/native-patches.mjs)提供版本 / 哈希检查、应用与还原;[补丁目录](native/)存放可重放变更。
121
- - 仓库内的测试覆盖层级、折叠状态、样式生命周期、客户端注册与[选择器交互](test/picker-ui.test.mjs),不读取真实凭据。
122
- - 公开仓库不包含本机的宿主备份、配置、会话或依赖手机目录的历史测试。适配器与原生设置页的完整集成验收需要单独准备对应版本的 DSH 环境。
123
- - [补丁安装器回归](test/native-patches.test.mjs)在临时目录实际应用 / 回退,并模拟写入失败、原子替换失败和并发修改;不重装或重启真实服务。
122
+ 设置 `DSH_ROOT` 可启用依赖对应 DSH 程序文件的回归测试。源码包含层级、选择器交互、面板布局及补丁安装与恢复测试。
124
123
 
125
- 这些自动化检查不等于真实浏览器端到端验收。手机窄屏布局、中文输入法、原生菜单 portal 和刷新后的实际卡片,仍须在原页面检查。未获得重启授权时,不会为了验收中断当前会话。
124
+ ## 许可证
126
125
 
127
- 客户端选择器改编自 DSH 的 MIT 代码,见 [LICENSE](LICENSE)。
126
+ [MIT](LICENSE)。包含改编自 DeepSeek Harness 的代码,保留原版权声明。
package/lib/client.js CHANGED
@@ -294,6 +294,29 @@ const MEASURE_STYLE = {
294
294
  * store/verbs) + the standard locale seat.
295
295
  * @returns the trigger and, while open, the two-level menu.
296
296
  */
297
+ // Lay out the entire cascade together: positioning a child alone can put it
298
+ // over an ancestor when neither side has room for another full-size card.
299
+ function layoutModelFlyouts(rect, viewportWidth, viewportHeight, count) {
300
+ const margin = Math.min(12, Math.max(0, viewportWidth / 8));
301
+ const available = Math.max(1, viewportWidth - margin * 2);
302
+ const preferredWidth = rect.width > 0 ? rect.width : 280;
303
+ const width = Math.min(preferredWidth, available / count);
304
+ const height = Math.min(rect.height > 0 ? rect.height : 360, Math.max(1, viewportHeight - margin * 2));
305
+ const leftRoom = rect.left - margin;
306
+ const rightRoom = viewportWidth - margin - (rect.left + preferredWidth);
307
+ const direction = leftRoom >= width * (count - 1) ? -1
308
+ : rightRoom >= width * (count - 1) ? 1 : leftRoom >= rightRoom ? -1 : 1;
309
+ const desiredLeft = direction < 0 ? rect.left - width * (count - 1) : rect.left;
310
+ const start = Math.max(margin, Math.min(desiredLeft, viewportWidth - margin - width * count));
311
+ const top = Math.max(margin, Math.min(rect.top, viewportHeight - margin - height));
312
+ return Array.from({ length: count }, (_, index) => ({
313
+ left: start + width * (direction < 0 ? count - 1 - index : index),
314
+ top, width, height,
315
+ // Override the native menu's intrinsic minimum and maximum at narrow widths.
316
+ minWidth: 0, maxWidth: width, maxHeight: height,
317
+ }));
318
+ }
319
+
297
320
  function ModelSelect({ locked, available, directory, load, select, t, renderSlot, providersForm }) {
298
321
  const preferences = react.useSyncExternalStore(groupPreferences.subscribe, groupPreferences.getSnapshot);
299
322
  const configuration = react.useSyncExternalStore(listener => providersForm?.subscribe(listener) ?? (() => {}), () => providersForm?.getSnapshot() ?? EMPTY_PROVIDERS_SNAPSHOT);
@@ -332,75 +355,18 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
332
355
  }
333
356
  };
334
357
 
335
- // 完全读取并匹配官方原版主卡片尺寸(宽、高严格保持与主卡片完全一致)
336
358
  const triggerFlyout = (entry, anchorEl) => {
337
359
  clearFlyoutTimer();
338
360
  if (activeFlyout?.key === entry.key) return;
339
361
  setActiveSubFlyout(null);
340
- const menuEl = menuRef.current;
341
- const menuRect = menuEl ? menuEl.getBoundingClientRect() : anchorEl.getBoundingClientRect();
342
- const margin = 12;
343
- const winW = window.innerWidth;
344
- const winH = window.innerHeight;
345
- // 直接读取官方主卡片的实际渲染宽度与高度
346
- const panelW = menuRect.width > 0 ? menuRect.width : 280;
347
- const panelH = menuRect.height > 0 ? menuRect.height : 360;
348
-
349
- // 紧靠主卡片左侧,0px 缝隙严丝合缝
350
- let left = menuRect.left - panelW;
351
- if (left < margin) {
352
- if (winW - menuRect.right >= panelW + margin) {
353
- left = menuRect.right;
354
- } else {
355
- left = Math.max(margin, Math.min(menuRect.left, winW - panelW - margin));
356
- }
357
- }
358
-
359
- // 顶部与官方主卡片绝对平齐,高度与官方主卡片 1:1 等高
360
- let top = menuRect.top;
361
- setActiveFlyout({
362
- key: entry.key,
363
- entry,
364
- pos: {
365
- left: Math.round(left),
366
- top: Math.round(top),
367
- width: Math.round(panelW),
368
- height: Math.round(panelH)
369
- }
370
- });
362
+ const rect = activeFlyout?.anchorRect ?? (menuRef.current ?? anchorEl).getBoundingClientRect();
363
+ setActiveFlyout({ key: entry.key, entry, anchorRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height } });
371
364
  };
372
365
 
373
- const triggerSubFlyout = (childGroup, anchorEl) => {
366
+ const triggerSubFlyout = (childGroup) => {
374
367
  clearFlyoutTimer();
375
368
  if (activeSubFlyout?.key === childGroup.key) return;
376
- const flyoutEl = flyoutRef.current;
377
- const flyoutRect = flyoutEl ? flyoutEl.getBoundingClientRect() : anchorEl.getBoundingClientRect();
378
- const margin = 12;
379
- const winW = window.innerWidth;
380
- const winH = window.innerHeight;
381
- const panelW = flyoutRect.width > 0 ? flyoutRect.width : 280;
382
- const panelH = flyoutRect.height > 0 ? flyoutRect.height : 360;
383
-
384
- let left = flyoutRect.left - panelW;
385
- if (left < margin) {
386
- if (winW - flyoutRect.right >= panelW + margin) {
387
- left = flyoutRect.right;
388
- } else {
389
- left = Math.max(margin, Math.min(flyoutRect.left, winW - panelW - margin));
390
- }
391
- }
392
-
393
- let top = flyoutRect.top;
394
- setActiveSubFlyout({
395
- key: childGroup.key,
396
- childGroup,
397
- pos: {
398
- left: Math.round(left),
399
- top: Math.round(top),
400
- width: Math.round(panelW),
401
- height: Math.round(panelH)
402
- }
403
- });
369
+ setActiveSubFlyout({ key: childGroup.key, childGroup });
404
370
  };
405
371
 
406
372
  const scheduleCloseFlyout = (delay = 240) => {
@@ -412,11 +378,14 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
412
378
  };
413
379
 
414
380
  const scheduleCloseSubFlyout = (delay = 240) => {
415
- if (subFlyoutTimerRef.current !== null) clearTimeout(subFlyoutTimerRef.current);
381
+ // Leaving the key panel must not let an older parent-leave timer
382
+ // destroy both the provider and key panels.
383
+ clearFlyoutTimer();
416
384
  subFlyoutTimerRef.current = setTimeout(() => {
417
385
  setActiveSubFlyout(null);
418
386
  }, delay);
419
387
  };
388
+ (0, react.useEffect)(() => () => clearFlyoutTimer(), []);
420
389
  const [toast, setToast] = (0, react.useState)(null);
421
390
  const toastSeq = (0, react.useRef)(0);
422
391
  const rootRef = (0, react.useRef)(null);
@@ -590,6 +559,9 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
590
559
  state,
591
560
  query
592
561
  ]);
562
+ const cascade = open && pane === "model" && menuMode === "panel" && !searching && activeFlyout
563
+ ? layoutModelFlyouts({ ...activeFlyout.anchorRect, ...(menuPos ?? {}) }, window.innerWidth, window.innerHeight, activeSubFlyout ? 3 : 2)
564
+ : null;
593
565
  if (!available) return null;
594
566
  const show = () => {
595
567
  setSelectionFocus(false);
@@ -853,7 +825,7 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
853
825
  title: searching ? text('搜索时自动展开,清空搜索后恢复', 'Expanded during search; clearing restores your groups') : entry.id || entry.name,
854
826
  onClick: (e) => {
855
827
  if (isFlyoutMode) {
856
- if (isFlyoutActive) setActiveFlyout(null);
828
+ if (isFlyoutActive) { clearFlyoutTimer(); setActiveFlyout(null); setActiveSubFlyout(null); }
857
829
  else triggerFlyout(entry, e.currentTarget);
858
830
  } else {
859
831
  setHighlightedIndex(null);
@@ -936,8 +908,9 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
936
908
  open && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
937
909
  ref: menuRef,
938
910
  id: `${id}-menu`,
939
- className: ModelSelect_module_css_default.menu,
940
- style: menuPos ?? MEASURE_STYLE,
911
+ className: clsx(ModelSelect_module_css_default.menu, cascade && "dsh-mg-cascade-panel"),
912
+ "data-flyout-level": cascade ? "0" : undefined,
913
+ style: cascade?.[0] ?? menuPos ?? MEASURE_STYLE,
941
914
  role: pane === "model" ? "group" : "menu",
942
915
  "aria-label": t("menu.aria"),
943
916
  "aria-busy": state.status === "loading" || busy,
@@ -1126,18 +1099,12 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
1126
1099
  }, level.key))] })
1127
1100
  ]
1128
1101
  }), document.body),
1129
- open && pane === "model" && menuMode === "panel" && activeFlyout !== null && (0, react_dom.createPortal)(
1102
+ cascade !== null && activeFlyout !== null && (0, react_dom.createPortal)(
1130
1103
  react_jsx_runtime.jsx(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1131
1104
  ref: flyoutRef,
1132
- className: ModelSelect_module_css_default.menu,
1133
- style: {
1134
- left: activeFlyout.pos.left,
1135
- top: activeFlyout.pos.top,
1136
- width: activeFlyout.pos.width,
1137
- height: activeFlyout.pos.height,
1138
- position: "fixed",
1139
- margin: 0
1140
- },
1105
+ className: clsx(ModelSelect_module_css_default.menu, "dsh-mg-cascade-panel"),
1106
+ "data-flyout-level": "1",
1107
+ style: { ...cascade[1], position: "fixed", margin: 0 },
1141
1108
  onMouseEnter: clearFlyoutTimer,
1142
1109
  onMouseLeave: () => scheduleCloseFlyout(200),
1143
1110
  onMouseDown: (e) => {
@@ -1176,18 +1143,12 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
1176
1143
  }),
1177
1144
  document.body
1178
1145
  ),
1179
- open && pane === "model" && menuMode === "panel" && activeSubFlyout !== null && (0, react_dom.createPortal)(
1146
+ cascade !== null && activeSubFlyout !== null && (0, react_dom.createPortal)(
1180
1147
  react_jsx_runtime.jsx(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1181
1148
  ref: subFlyoutRef,
1182
- className: ModelSelect_module_css_default.menu,
1183
- style: {
1184
- left: activeSubFlyout.pos.left,
1185
- top: activeSubFlyout.pos.top,
1186
- width: activeSubFlyout.pos.width,
1187
- height: activeSubFlyout.pos.height,
1188
- position: "fixed",
1189
- margin: 0
1190
- },
1149
+ className: clsx(ModelSelect_module_css_default.menu, "dsh-mg-cascade-panel"),
1150
+ "data-flyout-level": "2",
1151
+ style: { ...cascade[2], position: "fixed", margin: 0 },
1191
1152
  onMouseEnter: clearFlyoutTimer,
1192
1153
  onMouseLeave: () => scheduleCloseSubFlyout(200),
1193
1154
  onMouseDown: (e) => {
@@ -1286,7 +1247,7 @@ class ModelSeatBoundary extends react.Component {
1286
1247
  }
1287
1248
 
1288
1249
 
1289
- const MODEL_GROUPS_CSS = "/* DSH Model Groups: 100% native token-compatible styling */\n\n.dsh-mg-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 4px;\n flex-shrink: 0;\n margin: 2px 0 4px;\n padding: 0 4px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-summary {\n flex: 1;\n min-width: 0;\n padding: 0 2px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.dsh-mg-toolbar-actions {\n display: flex;\n align-items: center;\n gap: 4px;\n flex: none;\n}\n\n.dsh-mg-toolbar button {\n flex: none;\n height: 22px;\n padding: 0 6px;\n border: 0;\n border-radius: var(--dsw-radius-sm);\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font: inherit;\n font-size: 11px;\n line-height: 22px;\n cursor: pointer;\n transition: background .12s, color .12s;\n}\n\n.dsh-mg-toolbar button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n.dsh-mg-toolbar button:disabled {\n color: var(--dsw-alias-label-dimmed);\n cursor: default;\n}\n\n.dsh-mg-groupStart {\n display: block;\n height: 0;\n pointer-events: none;\n}\n\n.dsh-mg-groupHeading {\n border-radius: var(--dsw-radius-md);\n margin: 1px 0;\n}\n\n.dsh-mg-majorHeading {\n position: static;\n}\n\n.dsh-mg-minorHeading {\n position: relative;\n}\n\n.dsh-mg-majorContent {\n margin-left: 6px;\n padding-left: 6px;\n border-left: 1px solid var(--dsw-alias-border-l1);\n}\n\n.dsh-mg-minorContent {\n margin-left: 6px;\n padding-left: 6px;\n border-left: 1px solid var(--dsw-alias-border-l1);\n}\n\n.dsh-mg-source {\n display: block;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n}\n\n.dsh-mg-triggerSource {\n color: var(--dsw-alias-label-caption);\n font-size: 13px;\n}\n\n.dsh-mg-groupHeading button {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 32px;\n padding: 4px 7px;\n border: 0;\n border-radius: var(--dsw-radius-md);\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n line-height: 18px;\n text-align: left;\n cursor: pointer;\n transition: background .12s;\n}\n\n.dsh-mg-groupHeading button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-mg-groupHeading button:disabled {\n cursor: default;\n}\n\n.dsh-mg-groupHeading button:focus-visible,\n.dsh-mg-toolbar button:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\n}\n\n.dsh-mg-groupArrow {\n flex: none;\n width: 14px;\n height: 14px;\n color: var(--dsw-alias-label-caption);\n transition: transform .12s;\n}\n\n.dsh-mg-groupArrowOpen {\n transform: rotate(90deg);\n}\n\n.dsh-mg-groupName {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.dsh-mg-groupCount {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n font-weight: 400;\n line-height: 16px;\n}\n\n.dsh-mg-currentBadge {\n flex: none;\n color: var(--dsw-alias-state-business-primary);\n font-size: 11px;\n font-weight: 500;\n line-height: 16px;\n}\n\n.dsh-mg-groups {\n min-height: 0;\n}\n\n.dsh-mg-groups [hidden] {\n display: none !important;\n}\n\n/* ── 飞出卡片:与官方原版菜单 100% 结构一致,剔除 header 伪分层与白色叠加 ── */\n.dsh-mg-flyout-subgroup-list {\n display: flex;\n flex-direction: column;\n}\n\n.dsh-mg-subgroup-item {\n box-sizing: border-box;\n border-radius: var(--dsw-radius-md);\n width: auto;\n min-width: 100%;\n min-height: 34px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n cursor: pointer;\n background: 0 0;\n border: none;\n outline: none;\n align-items: center;\n gap: 6px;\n padding: 5px 7px;\n display: flex;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n transition: background .12s;\n}\n\n.dsh-mg-subgroup-item:hover,\n.dsh-mg-subgroup-item[data-active=\"true\"] {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-mg-subgroup-name {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.dsh-mg-subgroup-count {\n flex: none;\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-subgroup-arrow {\n flex: none;\n color: var(--dsw-alias-menu-icon, var(--dsw-alias-label-caption));\n}\n\n.dsh-mg-active-heading {\n background: var(--dsw-alias-interactive-bg-hover) !important;\n}\n\n/* ── 底部模式切换:原生轻量化,无突兀高饱和蓝块 ── */\n.dsh-mg-modebar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 6px 2px;\n margin-top: 3px;\n border-top: 1px solid var(--dsw-alias-border-l1);\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-mode-btn {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font: inherit;\n font-size: 11px;\n padding: 2px 6px;\n border-radius: var(--dsw-radius-sm);\n cursor: pointer;\n transition: background .12s, color .12s;\n}\n\n.dsh-mg-mode-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n.dsh-mg-mode-btn[data-active=\"true\"] {\n background: var(--dsw-alias-interactive-bg-active, rgba(255, 255, 255, 0.1));\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n\n@media (pointer: coarse) {\n .dsh-mg-groupHeading button { min-height: 36px; }\n .dsh-mg-toolbar button { height: 26px; }\n .dsh-mg-mode-btn { min-height: 24px; padding: 2px 8px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .dsh-mg-groupArrow { transition: none; }\n}\n\n";
1250
+ const MODEL_GROUPS_CSS = "/* DSH Model Groups: 100% native token-compatible styling */\n\n.dsh-mg-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 4px;\n flex-shrink: 0;\n margin: 2px 0 4px;\n padding: 0 4px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-summary {\n flex: 1;\n min-width: 0;\n padding: 0 2px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.dsh-mg-toolbar-actions {\n display: flex;\n align-items: center;\n gap: 4px;\n flex: none;\n}\n\n.dsh-mg-toolbar button {\n flex: none;\n height: 22px;\n padding: 0 6px;\n border: 0;\n border-radius: var(--dsw-radius-sm);\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font: inherit;\n font-size: 11px;\n line-height: 22px;\n cursor: pointer;\n transition: background .12s, color .12s;\n}\n\n.dsh-mg-toolbar button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n.dsh-mg-toolbar button:disabled {\n color: var(--dsw-alias-label-dimmed);\n cursor: default;\n}\n\n.dsh-mg-groupStart {\n display: block;\n height: 0;\n pointer-events: none;\n}\n\n.dsh-mg-groupHeading {\n border-radius: var(--dsw-radius-md);\n margin: 1px 0;\n}\n\n.dsh-mg-majorHeading {\n position: static;\n}\n\n.dsh-mg-minorHeading {\n position: relative;\n}\n\n.dsh-mg-majorContent {\n margin-left: 6px;\n padding-left: 6px;\n border-left: 1px solid var(--dsw-alias-border-l1);\n}\n\n.dsh-mg-minorContent {\n margin-left: 6px;\n padding-left: 6px;\n border-left: 1px solid var(--dsw-alias-border-l1);\n}\n\n.dsh-mg-source {\n display: block;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n}\n\n.dsh-mg-triggerSource {\n color: var(--dsw-alias-label-caption);\n font-size: 13px;\n}\n\n.dsh-mg-groupHeading button {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 32px;\n padding: 4px 7px;\n border: 0;\n border-radius: var(--dsw-radius-md);\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n font-weight: 500;\n line-height: 18px;\n text-align: left;\n cursor: pointer;\n transition: background .12s;\n}\n\n.dsh-mg-groupHeading button:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-mg-groupHeading button:disabled {\n cursor: default;\n}\n\n.dsh-mg-groupHeading button:focus-visible,\n.dsh-mg-toolbar button:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\n}\n\n.dsh-mg-groupArrow {\n flex: none;\n width: 14px;\n height: 14px;\n color: var(--dsw-alias-label-caption);\n transition: transform .12s;\n}\n\n.dsh-mg-groupArrowOpen {\n transform: rotate(90deg);\n}\n\n.dsh-mg-groupName {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.dsh-mg-groupCount {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n font-weight: 400;\n line-height: 16px;\n}\n\n.dsh-mg-currentBadge {\n flex: none;\n color: var(--dsw-alias-state-business-primary);\n font-size: 11px;\n font-weight: 500;\n line-height: 16px;\n}\n\n.dsh-mg-groups {\n min-height: 0;\n}\n\n.dsh-mg-groups [hidden] {\n display: none !important;\n}\n\n/* ── 飞出卡片:与官方原版菜单 100% 结构一致,剔除 header 伪分层与白色叠加 ── */\n.dsh-mg-flyout-subgroup-list {\n display: flex;\n flex-direction: column;\n}\n\n.dsh-mg-subgroup-item {\n box-sizing: border-box;\n border-radius: var(--dsw-radius-md);\n width: auto;\n min-width: 100%;\n min-height: 34px;\n color: var(--dsw-alias-label-primary);\n text-align: left;\n cursor: pointer;\n background: 0 0;\n border: none;\n outline: none;\n align-items: center;\n gap: 6px;\n padding: 5px 7px;\n display: flex;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n transition: background .12s;\n}\n\n.dsh-mg-subgroup-item:hover,\n.dsh-mg-subgroup-item[data-active=\"true\"] {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-mg-subgroup-name {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.dsh-mg-subgroup-count {\n flex: none;\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-subgroup-arrow {\n flex: none;\n color: var(--dsw-alias-menu-icon, var(--dsw-alias-label-caption));\n}\n\n.dsh-mg-active-heading {\n background: var(--dsw-alias-interactive-bg-hover) !important;\n}\n\n/* ── 底部模式切换:原生轻量化,无突兀高饱和蓝块 ── */\n.dsh-mg-modebar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 4px 6px 2px;\n margin-top: 3px;\n border-top: 1px solid var(--dsw-alias-border-l1);\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.dsh-mg-mode-btn {\n border: 0;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font: inherit;\n font-size: 11px;\n padding: 2px 6px;\n border-radius: var(--dsw-radius-sm);\n cursor: pointer;\n transition: background .12s, color .12s;\n}\n\n.dsh-mg-mode-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n.dsh-mg-mode-btn[data-active=\"true\"] {\n background: var(--dsw-alias-interactive-bg-active, rgba(255, 255, 255, 0.1));\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n\n@media (pointer: coarse) {\n .dsh-mg-groupHeading button { min-height: 36px; }\n .dsh-mg-toolbar button { height: 26px; }\n .dsh-mg-mode-btn { min-height: 24px; padding: 2px 8px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .dsh-mg-groupArrow { transition: none; }\n}\n\n\n/* Cascade columns share the viewport rather than covering their ancestors. */\n.dsh-mg-cascade-panel {\n box-sizing: border-box;\n min-width: 0;\n}\n.dsh-mg-cascade-panel button,\n.dsh-mg-cascade-panel [role=\"menu\"],\n.dsh-mg-cascade-panel .dsh-mg-modebar,\n.dsh-mg-cascade-panel .dsh-mg-toolbar {\n min-width: 0;\n}\n.dsh-mg-cascade-panel .dsh-mg-toolbar,\n.dsh-mg-cascade-panel .dsh-mg-modebar,\n.dsh-mg-cascade-panel .dsh-mg-toolbar-actions {\n flex-wrap: wrap;\n}\n.dsh-mg-cascade-panel .dsh-mg-summary {\n white-space: normal;\n}\n";
1290
1251
  function installModelGroupsStyles() {
1291
1252
  const tagId = 'dsh-model-groups/groups.css';
1292
1253
  if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return;
@@ -1305,6 +1266,22 @@ function apply(ctx) {
1305
1266
  installModelSelectStyles();
1306
1267
  installModelGroupsStyles();
1307
1268
  const providersForm = ctx.configForms?.get('llm-pi-ai');
1269
+ let statusDisposed = false;
1270
+ let statusNotice;
1271
+ if (typeof fetch === 'function') Promise.resolve().then(() => fetch('/dsh-model-groups/status', { credentials: 'same-origin', cache: 'no-store' }))
1272
+ .then(response => { if (!response.ok) throw new Error('status unavailable'); return response.json(); })
1273
+ .then(status => {
1274
+ if (statusDisposed || (!status.restartRequired && !status.failed)) return;
1275
+ statusNotice = document.createElement('div');
1276
+ statusNotice.dataset.plugin = 'dsh-model-groups';
1277
+ statusNotice.setAttribute('role', 'alertdialog');
1278
+ statusNotice.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#222);display:grid;place-items:center;padding:24px;box-sizing:border-box;';
1279
+ statusNotice.textContent = status.failed
1280
+ ? '多密钥自动补丁未完成。请回到 App 对话查看引擎日志或运行安装诊断,勿强制覆盖。当前网页暂不可用于编辑模型配置。'
1281
+ : '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App 重启原引擎并刷新此网页。当前网页暂停操作,避免新旧模块混用。';
1282
+ document.body.appendChild(statusNotice);
1283
+ }).catch(() => {});
1284
+ ctx.on('dispose', () => { statusDisposed = true; statusNotice?.remove(); });
1308
1285
  const onStorage = event => {
1309
1286
  if (event.key === STORAGE_KEY || event.key === null) groupPreferences.sync(event.key === null ? null : event.newValue);
1310
1287
  };
@@ -4,6 +4,18 @@
4
4
  "target": "node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js",
5
5
  "baseSha256": "070c839e2c00e2d5f7c5d82307ca3d84dbb2323e46979daf265908751bbe8ae8",
6
6
  "resultSha256": "a2b3bcda38015026ed58074cef0d508bbd2d7cb1423c00331651fcbd1a9f6b55",
7
+ "variants": [
8
+ {
9
+ "source": "0.2.2-rc.1 with tool lookup optimization and credential cancellation",
10
+ "baseSha256": "0a99a2fc903a4285044b09114b3bd20ee1e612f39aa381e4a71b10d48ee9e9f7",
11
+ "resultSha256": "c87f965d9fcc5bbcd386e98ea3b292b05ec7a55d80df1266e18234031b80990e"
12
+ },
13
+ {
14
+ "source": "npm official 0.2.2-rc.1",
15
+ "baseSha256": "1a3ae58f9b4493e15459cc5000ec80f21b5d3f4a9dea6ffb6bc28e5b8817ae8a",
16
+ "resultSha256": "b9416207cc7c27aff40cb2b877c9176e53809eb2f6dc741828c2ed6f6bdf541b"
17
+ }
18
+ ],
7
19
  "replacements": [
8
20
  {
9
21
  "before": "\t...modelFields\n});\n/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */\nconst modelOverride = z.object(modelFields);\nconst profile = z.object({\n\tapiKeyEnv: z.string().role(\"credential-ref\"),\n\tdisplayName: z.string(),\n\tapi: z.union(supportedProtocols()),\n\tbaseURL: z.string(),\n",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-model-groups",
3
- "version": "0.3.2",
4
- "description": "同一供应商的多密钥配置与供应商→密钥配置→模型折叠选择,手动精确路由(0.2.2-rc.1 移植版)。",
3
+ "version": "0.3.4",
4
+ "description": "DSH 网页模型选择器:供应商与密钥配置分层折叠、多密钥手动选择。支持 DSH 0.2.2-rc.1。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -61,6 +61,7 @@
61
61
  "test": "node --test test/*.test.mjs",
62
62
  "test:ui": "node --test test/picker-ui.test.mjs",
63
63
  "native:check": "node scripts/native-patches.mjs --check",
64
+ "doctor": "node scripts/doctor.mjs",
64
65
  "prepack": "npm run build && npm run check:build"
65
66
  }
66
67
  }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { planPatches } from './native-patches.mjs';
4
+
5
+ const args = process.argv.slice(2);
6
+ if (args.includes('--help')) {
7
+ console.log('Usage: node scripts/doctor.mjs --dsh-root <DSH installation directory> [--json]\nRead-only installation check. Does not read credentials or change DSH.');
8
+ } else {
9
+ try {
10
+ const i = args.indexOf('--dsh-root');
11
+ if (i < 0 || !args[i + 1] || args.some((arg, index) => index !== i + 1 && !['--dsh-root', '--json'].includes(arg))) {
12
+ throw new Error('请用 --dsh-root 指定正在使用的 DSH 安装目录。');
13
+ }
14
+ const pkg = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
15
+ const plans = planPatches(args[i + 1], 'check');
16
+ const missing = plans.filter(plan => plan.state !== 'patched');
17
+ const report = {
18
+ plugin: pkg.name,
19
+ version: pkg.version,
20
+ supportedDsh: pkg.engines.dsh,
21
+ diskPatchesReady: missing.length === 0,
22
+ runtimeVerified: false,
23
+ files: plans.map(plan => ({ target: plan.artifact.target, state: plan.state })),
24
+ next: missing.length > 0
25
+ ? '多密钥安装尚未完成:需要按 README 应用尚未安装的兼容补丁,再重启原 DSH 服务并刷新网页。'
26
+ : '磁盘补丁齐全。请确认原 DSH 服务已在应用补丁后重启,并刷新网页;本检查不能确认运行中的模块或浏览器缓存。',
27
+ };
28
+ if (args.includes('--json')) console.log(JSON.stringify(report, null, 2));
29
+ else {
30
+ console.log(`${report.plugin} ${report.version} · DSH ${report.supportedDsh}`);
31
+ for (const item of report.files) console.log(`${item.state}: ${item.target}`);
32
+ console.log(report.next);
33
+ }
34
+ if (missing.length) process.exitCode = 2;
35
+ } catch (error) {
36
+ console.error(`无法确认多密钥安装状态:${error.message}`);
37
+ process.exitCode = 1;
38
+ }
39
+ }
@@ -32,7 +32,7 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
32
32
  if (dsh.name !== '@deepseek-ai/dsh' || dsh.version !== '0.2.2-rc.1') throw new Error('This patch supports only @deepseek-ai/dsh 0.2.2-rc.1');
33
33
  const seen = new Set();
34
34
  return ARTIFACTS.map(name => {
35
- const artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
35
+ let artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
36
36
  if (artifact.version !== '0.2.2-rc.1' || !/^@deepseek-ai\/dsh-[a-z-]+$/.test(artifact.packageName)) throw new Error('Unsupported patch package/version');
37
37
  if (!Array.isArray(artifact.replacements) || artifact.replacements.length === 0 || !/^[0-9a-f]{64}$/.test(artifact.baseSha256) || !/^[0-9a-f]{64}$/.test(artifact.resultSha256)) throw new Error('Invalid patch manifest');
38
38
  const prefix = `node_modules/${artifact.packageName}/`;
@@ -44,6 +44,13 @@ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.jo
44
44
  if (pkg.name !== artifact.packageName || pkg.version !== artifact.version) throw new Error('Installed native package version mismatch');
45
45
  const source = fs.readFileSync(file, 'utf8');
46
46
  const hash = sha256(source);
47
+ if (artifact.variants !== undefined) {
48
+ if (!Array.isArray(artifact.variants) || artifact.variants.some(variant => !/^[0-9a-f]{64}$/.test(variant.baseSha256) || !/^[0-9a-f]{64}$/.test(variant.resultSha256))) throw new Error('Invalid patch variants');
49
+ const candidates = [artifact, ...artifact.variants];
50
+ const matched = candidates.filter(variant => hash === variant.baseSha256 || hash === variant.resultSha256);
51
+ if (matched.length > 1) throw new Error('Ambiguous patch variant');
52
+ if (matched.length === 1) artifact = { ...artifact, baseSha256: matched[0].baseSha256, resultSha256: matched[0].resultSha256 };
53
+ }
47
54
  const state = hash === artifact.baseSha256 ? 'pristine' : hash === artifact.resultSha256 ? 'patched' : 'modified';
48
55
  if (state === 'modified') throw new Error(`Refusing unrecognized content: ${artifact.target}`);
49
56
  // A no-op still verifies both directions to detect a broken manifest.
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { planPatches, applyPlan } from './native-patches.mjs';
5
+
6
+ const args = process.argv.slice(2);
7
+ try {
8
+ if (args.includes('--help')) {
9
+ console.log('Usage: node scripts/setup.mjs --dsh-root <directory> --profile <profile> [--apply --service-stopped]\nDefault: read-only compatibility check. Apply registers the plugin and patches DSH. Stop the original DSH service first; restart it afterwards.');
10
+ } else {
11
+ const values = {};
12
+ let apply = false, stopped = false;
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === '--apply') apply = true;
16
+ else if (arg === '--service-stopped') stopped = true;
17
+ else if (['--dsh-root', '--profile'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
18
+ else throw new Error(`未知或不完整的参数:${arg}`);
19
+ }
20
+ const root = values['--dsh-root'];
21
+ const profile = values['--profile'];
22
+ if (!root || !profile || !/^[A-Za-z0-9_-]+$/.test(profile)) throw new Error('请提供 --dsh-root 和有效的 --profile。');
23
+ if (apply && !stopped) throw new Error('应用前请停止原 DSH 服务,再提供 --apply --service-stopped。该参数由你确认停机,不会自动检测或停止服务。');
24
+ const plans = planPatches(root, 'check');
25
+ for (const plan of plans) console.log(`${plan.state}: ${plan.artifact.target}`);
26
+ if (!apply) {
27
+ console.log('兼容检查通过,尚未注册插件或应用补丁。停止原服务后,用相同命令加 --apply --service-stopped 完成安装。');
28
+ } else {
29
+ const pluginRoot = fileURLToPath(new URL('../', import.meta.url));
30
+ const registered = spawnSync('dsh', ['plugin', '--profile', profile, 'add', path.resolve(pluginRoot)], { stdio: 'inherit' });
31
+ if (registered.error || registered.status !== 0) throw new Error('插件注册失败,未应用补丁。请检查上方安装输出。');
32
+ const count = applyPlan(planPatches(root, 'apply'));
33
+ if (!planPatches(root, 'check').every(plan => plan.state === 'patched')) throw new Error('补丁复查未通过。插件可能已注册,请检查磁盘状态后再启动服务。');
34
+ console.log(`插件已注册,${count} 个宿主文件已更新,五项磁盘补丁全部就绪。请启动原 DSH 服务并刷新原网页;当前尚未验证运行中的功能。`);
35
+ }
36
+ }
37
+ } catch (error) {
38
+ console.error(`安装停止:${error.message}`);
39
+ process.exitCode = 1;
40
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { planPatches, applyPlan, sha256 } from '../scripts/native-patches.mjs';
7
+
8
+ export function findDshRoot(entry = process.argv[1]) {
9
+ if (!entry) throw new Error('无法确定 DSH 进程入口。');
10
+ const require = createRequire(fs.realpathSync(entry));
11
+ const pkg = require.resolve('@deepseek-ai/dsh-llm-pi-ai/package.json');
12
+ const resolved = fs.realpathSync(pkg);
13
+ let dir = path.dirname(resolved);
14
+ while (true) {
15
+ const candidate = path.join(dir, 'node_modules/@deepseek-ai/dsh-llm-pi-ai/package.json');
16
+ if (fs.existsSync(candidate) && fs.realpathSync(candidate) === resolved) {
17
+ // Full patch planning validates the DSH package/version and every target.
18
+ planPatches(dir, 'check');
19
+ return dir;
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+ throw new Error('未找到与当前 DSH 进程一致且可安全修改的安装目录。');
26
+ }
27
+
28
+ export function autoPatch(root, home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')) {
29
+ const plans = planPatches(root, 'apply');
30
+ const pending = plans.some(plan => plan.source !== plan.result);
31
+ if (!pending) return { state: 'disk-ready', changed: 0, runtimeVerified: false };
32
+ const storage = path.join(home, 'dsh-model-groups', sha256(fs.realpathSync(root)).slice(0, 16));
33
+ fs.mkdirSync(storage, { recursive: true, mode: 0o700 });
34
+ const lock = path.join(storage, 'patch.lock');
35
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
36
+ catch { throw new Error('补丁安装已被锁定;可能有另一进程正在安装。确认没有安装任务后再处理锁目录,勿并发重试。'); }
37
+ try {
38
+ const changed = applyPlan(planPatches(root, 'apply'), path.join(storage, 'backups'));
39
+ if (!planPatches(root, 'check').every(plan => plan.state === 'patched')) throw new Error('自动补丁复查失败。');
40
+ return { state: changed ? 'restart-required' : 'disk-ready', changed, runtimeVerified: false };
41
+ } finally {
42
+ if (path.dirname(lock) !== storage || fs.realpathSync(lock) !== lock || !fs.lstatSync(lock).isDirectory()) throw new Error('补丁锁路径异常。');
43
+ fs.rmdirSync(lock);
44
+ }
45
+ }
package/src/client.js CHANGED
@@ -6,6 +6,22 @@ export function apply(ctx) {
6
6
  installModelSelectStyles();
7
7
  installModelGroupsStyles();
8
8
  const providersForm = ctx.configForms?.get('llm-pi-ai');
9
+ let statusDisposed = false;
10
+ let statusNotice;
11
+ if (typeof fetch === 'function') Promise.resolve().then(() => fetch('/dsh-model-groups/status', { credentials: 'same-origin', cache: 'no-store' }))
12
+ .then(response => { if (!response.ok) throw new Error('status unavailable'); return response.json(); })
13
+ .then(status => {
14
+ if (statusDisposed || (!status.restartRequired && !status.failed)) return;
15
+ statusNotice = document.createElement('div');
16
+ statusNotice.dataset.plugin = 'dsh-model-groups';
17
+ statusNotice.setAttribute('role', 'alertdialog');
18
+ statusNotice.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#222);display:grid;place-items:center;padding:24px;box-sizing:border-box;';
19
+ statusNotice.textContent = status.failed
20
+ ? '多密钥自动补丁未完成。请回到 App 对话查看引擎日志或运行安装诊断,勿强制覆盖。当前网页暂不可用于编辑模型配置。'
21
+ : '多密钥补丁已自动安装。请先保存其他页面的草稿,再到 App 重启原引擎并刷新此网页。当前网页暂停操作,避免新旧模块混用。';
22
+ document.body.appendChild(statusNotice);
23
+ }).catch(() => {});
24
+ ctx.on('dispose', () => { statusDisposed = true; statusNotice?.remove(); });
9
25
  const onStorage = event => {
10
26
  if (event.key === STORAGE_KEY || event.key === null) groupPreferences.sync(event.key === null ? null : event.newValue);
11
27
  };
package/src/groups.css CHANGED
@@ -278,3 +278,23 @@
278
278
  .dsh-mg-groupArrow { transition: none; }
279
279
  }
280
280
 
281
+
282
+ /* Cascade columns share the viewport rather than covering their ancestors. */
283
+ .dsh-mg-cascade-panel {
284
+ box-sizing: border-box;
285
+ min-width: 0;
286
+ }
287
+ .dsh-mg-cascade-panel button,
288
+ .dsh-mg-cascade-panel [role="menu"],
289
+ .dsh-mg-cascade-panel .dsh-mg-modebar,
290
+ .dsh-mg-cascade-panel .dsh-mg-toolbar {
291
+ min-width: 0;
292
+ }
293
+ .dsh-mg-cascade-panel .dsh-mg-toolbar,
294
+ .dsh-mg-cascade-panel .dsh-mg-modebar,
295
+ .dsh-mg-cascade-panel .dsh-mg-toolbar-actions {
296
+ flex-wrap: wrap;
297
+ }
298
+ .dsh-mg-cascade-panel .dsh-mg-summary {
299
+ white-space: normal;
300
+ }
package/src/index.js CHANGED
@@ -1,4 +1,29 @@
1
- // Client-only picker. Native multi-key support is installed explicitly via
2
- // scripts/native-patches.mjs, never by mutating runtime code during apply.
3
- // This plugin owns no provider credentials or independent grouping settings.
4
- export function apply() {}
1
+ import { findDshRoot, autoPatch } from './auto-patch.js';
2
+
3
+ // Explicit patch distribution: installation/loading validates and updates the
4
+ // known DSH disk files. It never forces module reload, restart or config changes.
5
+ export function apply(ctx) {
6
+ const key = Symbol.for('dsh-model-groups.patch-state');
7
+ const state = globalThis[key] ??= { restartRequired: false, error: null };
8
+ ctx.inject(['webserver'], child => {
9
+ child.effect(() => child.webserver.register({
10
+ kind: 'exact', path: '/dsh-model-groups/status',
11
+ handler(req, res) {
12
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
13
+ res.end(JSON.stringify({ restartRequired: state.restartRequired, failed: state.error !== null }));
14
+ },
15
+ }));
16
+ });
17
+ try {
18
+ const result = autoPatch(findDshRoot());
19
+ if (result.state === 'restart-required') {
20
+ state.restartRequired = true;
21
+ ctx.logger.warn(`多密钥兼容补丁已自动安装(${result.changed} 项)。请保存草稿,在 App 重启原引擎并刷新原网页;当前进程尚未验证生效。`);
22
+ } else {
23
+ ctx.logger.info('多密钥磁盘补丁已就绪。若本次进程尚未在打补丁后重启,请重启原引擎并刷新网页。');
24
+ }
25
+ } catch (error) {
26
+ state.error = error.message;
27
+ ctx.logger.error(`多密钥自动安装未完成:${error.message}。未强制覆盖未知文件,请运行安装诊断;不要把模型分组出现视为多密钥已就绪。`);
28
+ }
29
+ }
@@ -111,6 +111,29 @@ const MEASURE_STYLE = {
111
111
  * store/verbs) + the standard locale seat.
112
112
  * @returns the trigger and, while open, the two-level menu.
113
113
  */
114
+ // Lay out the entire cascade together: positioning a child alone can put it
115
+ // over an ancestor when neither side has room for another full-size card.
116
+ function layoutModelFlyouts(rect, viewportWidth, viewportHeight, count) {
117
+ const margin = Math.min(12, Math.max(0, viewportWidth / 8));
118
+ const available = Math.max(1, viewportWidth - margin * 2);
119
+ const preferredWidth = rect.width > 0 ? rect.width : 280;
120
+ const width = Math.min(preferredWidth, available / count);
121
+ const height = Math.min(rect.height > 0 ? rect.height : 360, Math.max(1, viewportHeight - margin * 2));
122
+ const leftRoom = rect.left - margin;
123
+ const rightRoom = viewportWidth - margin - (rect.left + preferredWidth);
124
+ const direction = leftRoom >= width * (count - 1) ? -1
125
+ : rightRoom >= width * (count - 1) ? 1 : leftRoom >= rightRoom ? -1 : 1;
126
+ const desiredLeft = direction < 0 ? rect.left - width * (count - 1) : rect.left;
127
+ const start = Math.max(margin, Math.min(desiredLeft, viewportWidth - margin - width * count));
128
+ const top = Math.max(margin, Math.min(rect.top, viewportHeight - margin - height));
129
+ return Array.from({ length: count }, (_, index) => ({
130
+ left: start + width * (direction < 0 ? count - 1 - index : index),
131
+ top, width, height,
132
+ // Override the native menu's intrinsic minimum and maximum at narrow widths.
133
+ minWidth: 0, maxWidth: width, maxHeight: height,
134
+ }));
135
+ }
136
+
114
137
  function ModelSelect({ locked, available, directory, load, select, t, renderSlot, providersForm }) {
115
138
  const preferences = react.useSyncExternalStore(groupPreferences.subscribe, groupPreferences.getSnapshot);
116
139
  const configuration = react.useSyncExternalStore(listener => providersForm?.subscribe(listener) ?? (() => {}), () => providersForm?.getSnapshot() ?? EMPTY_PROVIDERS_SNAPSHOT);
@@ -149,75 +172,18 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
149
172
  }
150
173
  };
151
174
 
152
- // 完全读取并匹配官方原版主卡片尺寸(宽、高严格保持与主卡片完全一致)
153
175
  const triggerFlyout = (entry, anchorEl) => {
154
176
  clearFlyoutTimer();
155
177
  if (activeFlyout?.key === entry.key) return;
156
178
  setActiveSubFlyout(null);
157
- const menuEl = menuRef.current;
158
- const menuRect = menuEl ? menuEl.getBoundingClientRect() : anchorEl.getBoundingClientRect();
159
- const margin = 12;
160
- const winW = window.innerWidth;
161
- const winH = window.innerHeight;
162
- // 直接读取官方主卡片的实际渲染宽度与高度
163
- const panelW = menuRect.width > 0 ? menuRect.width : 280;
164
- const panelH = menuRect.height > 0 ? menuRect.height : 360;
165
-
166
- // 紧靠主卡片左侧,0px 缝隙严丝合缝
167
- let left = menuRect.left - panelW;
168
- if (left < margin) {
169
- if (winW - menuRect.right >= panelW + margin) {
170
- left = menuRect.right;
171
- } else {
172
- left = Math.max(margin, Math.min(menuRect.left, winW - panelW - margin));
173
- }
174
- }
175
-
176
- // 顶部与官方主卡片绝对平齐,高度与官方主卡片 1:1 等高
177
- let top = menuRect.top;
178
- setActiveFlyout({
179
- key: entry.key,
180
- entry,
181
- pos: {
182
- left: Math.round(left),
183
- top: Math.round(top),
184
- width: Math.round(panelW),
185
- height: Math.round(panelH)
186
- }
187
- });
179
+ const rect = activeFlyout?.anchorRect ?? (menuRef.current ?? anchorEl).getBoundingClientRect();
180
+ setActiveFlyout({ key: entry.key, entry, anchorRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height } });
188
181
  };
189
182
 
190
- const triggerSubFlyout = (childGroup, anchorEl) => {
183
+ const triggerSubFlyout = (childGroup) => {
191
184
  clearFlyoutTimer();
192
185
  if (activeSubFlyout?.key === childGroup.key) return;
193
- const flyoutEl = flyoutRef.current;
194
- const flyoutRect = flyoutEl ? flyoutEl.getBoundingClientRect() : anchorEl.getBoundingClientRect();
195
- const margin = 12;
196
- const winW = window.innerWidth;
197
- const winH = window.innerHeight;
198
- const panelW = flyoutRect.width > 0 ? flyoutRect.width : 280;
199
- const panelH = flyoutRect.height > 0 ? flyoutRect.height : 360;
200
-
201
- let left = flyoutRect.left - panelW;
202
- if (left < margin) {
203
- if (winW - flyoutRect.right >= panelW + margin) {
204
- left = flyoutRect.right;
205
- } else {
206
- left = Math.max(margin, Math.min(flyoutRect.left, winW - panelW - margin));
207
- }
208
- }
209
-
210
- let top = flyoutRect.top;
211
- setActiveSubFlyout({
212
- key: childGroup.key,
213
- childGroup,
214
- pos: {
215
- left: Math.round(left),
216
- top: Math.round(top),
217
- width: Math.round(panelW),
218
- height: Math.round(panelH)
219
- }
220
- });
186
+ setActiveSubFlyout({ key: childGroup.key, childGroup });
221
187
  };
222
188
 
223
189
  const scheduleCloseFlyout = (delay = 240) => {
@@ -229,11 +195,14 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
229
195
  };
230
196
 
231
197
  const scheduleCloseSubFlyout = (delay = 240) => {
232
- if (subFlyoutTimerRef.current !== null) clearTimeout(subFlyoutTimerRef.current);
198
+ // Leaving the key panel must not let an older parent-leave timer
199
+ // destroy both the provider and key panels.
200
+ clearFlyoutTimer();
233
201
  subFlyoutTimerRef.current = setTimeout(() => {
234
202
  setActiveSubFlyout(null);
235
203
  }, delay);
236
204
  };
205
+ (0, react.useEffect)(() => () => clearFlyoutTimer(), []);
237
206
  const [toast, setToast] = (0, react.useState)(null);
238
207
  const toastSeq = (0, react.useRef)(0);
239
208
  const rootRef = (0, react.useRef)(null);
@@ -407,6 +376,9 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
407
376
  state,
408
377
  query
409
378
  ]);
379
+ const cascade = open && pane === "model" && menuMode === "panel" && !searching && activeFlyout
380
+ ? layoutModelFlyouts({ ...activeFlyout.anchorRect, ...(menuPos ?? {}) }, window.innerWidth, window.innerHeight, activeSubFlyout ? 3 : 2)
381
+ : null;
410
382
  if (!available) return null;
411
383
  const show = () => {
412
384
  setSelectionFocus(false);
@@ -670,7 +642,7 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
670
642
  title: searching ? text('搜索时自动展开,清空搜索后恢复', 'Expanded during search; clearing restores your groups') : entry.id || entry.name,
671
643
  onClick: (e) => {
672
644
  if (isFlyoutMode) {
673
- if (isFlyoutActive) setActiveFlyout(null);
645
+ if (isFlyoutActive) { clearFlyoutTimer(); setActiveFlyout(null); setActiveSubFlyout(null); }
674
646
  else triggerFlyout(entry, e.currentTarget);
675
647
  } else {
676
648
  setHighlightedIndex(null);
@@ -753,8 +725,9 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
753
725
  open && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
754
726
  ref: menuRef,
755
727
  id: `${id}-menu`,
756
- className: ModelSelect_module_css_default.menu,
757
- style: menuPos ?? MEASURE_STYLE,
728
+ className: clsx(ModelSelect_module_css_default.menu, cascade && "dsh-mg-cascade-panel"),
729
+ "data-flyout-level": cascade ? "0" : undefined,
730
+ style: cascade?.[0] ?? menuPos ?? MEASURE_STYLE,
758
731
  role: pane === "model" ? "group" : "menu",
759
732
  "aria-label": t("menu.aria"),
760
733
  "aria-busy": state.status === "loading" || busy,
@@ -943,18 +916,12 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
943
916
  }, level.key))] })
944
917
  ]
945
918
  }), document.body),
946
- open && pane === "model" && menuMode === "panel" && activeFlyout !== null && (0, react_dom.createPortal)(
919
+ cascade !== null && activeFlyout !== null && (0, react_dom.createPortal)(
947
920
  react_jsx_runtime.jsx(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
948
921
  ref: flyoutRef,
949
- className: ModelSelect_module_css_default.menu,
950
- style: {
951
- left: activeFlyout.pos.left,
952
- top: activeFlyout.pos.top,
953
- width: activeFlyout.pos.width,
954
- height: activeFlyout.pos.height,
955
- position: "fixed",
956
- margin: 0
957
- },
922
+ className: clsx(ModelSelect_module_css_default.menu, "dsh-mg-cascade-panel"),
923
+ "data-flyout-level": "1",
924
+ style: { ...cascade[1], position: "fixed", margin: 0 },
958
925
  onMouseEnter: clearFlyoutTimer,
959
926
  onMouseLeave: () => scheduleCloseFlyout(200),
960
927
  onMouseDown: (e) => {
@@ -993,18 +960,12 @@ function ModelSelect({ locked, available, directory, load, select, t, renderSlot
993
960
  }),
994
961
  document.body
995
962
  ),
996
- open && pane === "model" && menuMode === "panel" && activeSubFlyout !== null && (0, react_dom.createPortal)(
963
+ cascade !== null && activeSubFlyout !== null && (0, react_dom.createPortal)(
997
964
  react_jsx_runtime.jsx(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
998
965
  ref: subFlyoutRef,
999
- className: ModelSelect_module_css_default.menu,
1000
- style: {
1001
- left: activeSubFlyout.pos.left,
1002
- top: activeSubFlyout.pos.top,
1003
- width: activeSubFlyout.pos.width,
1004
- height: activeSubFlyout.pos.height,
1005
- position: "fixed",
1006
- margin: 0
1007
- },
966
+ className: clsx(ModelSelect_module_css_default.menu, "dsh-mg-cascade-panel"),
967
+ "data-flyout-level": "2",
968
+ style: { ...cascade[2], position: "fixed", margin: 0 },
1008
969
  onMouseEnter: clearFlyoutTimer,
1009
970
  onMouseLeave: () => scheduleCloseSubFlyout(200),
1010
971
  onMouseDown: (e) => {