@lynn123411/dsh-model-submenu 0.2.5 → 0.3.0

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/lib/client.js CHANGED
@@ -8,8 +8,38 @@ window.__ModuleLoader__.load({
8
8
  let react_dom = require("react-dom");
9
9
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
+ //#region src/client/icons.tsx
12
+ /**
13
+ * The one glyph this plugin draws itself.
14
+ *
15
+ * The shared icon library carries no star, so the favorite mark is defined here:
16
+ * a five-point star whose outline and fill use the same path, which keeps the
17
+ * hover-only outline and the standing filled state identical in geometry.
18
+ */
19
+ /**
20
+ * Render the favorite star.
21
+ * @param props.filled - draw the solid star; otherwise the outline.
22
+ * @param props.size - square edge in pixels, matching the shared icon set's default.
23
+ * @param props.className - extra class for colour and placement.
24
+ * @returns the inline SVG.
25
+ */
26
+ function StarIcon({ filled, size = 14, className }) {
27
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
28
+ width: size,
29
+ height: size,
30
+ viewBox: "0 0 24 24",
31
+ fill: filled ? "currentColor" : "none",
32
+ stroke: "currentColor",
33
+ strokeWidth: filled ? 0 : 1.6,
34
+ strokeLinejoin: "round",
35
+ "aria-hidden": "true",
36
+ className,
37
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 3.2l2.7 5.6 6.1.85-4.45 4.3 1.08 6.05L12 17.1l-5.43 2.9 1.08-6.05L3.2 9.65l6.1-.85z" })
38
+ });
39
+ }
40
+ //#endregion
11
41
  //#region \0dsh-css:/Users/tny/Desktop/work/my-dsh/plugins/dsh-model-submenu/src/client/ModelSubmenu.module.css.mjs
12
- const css = ".FOsK3a_root{min-width:0;position:relative}.FOsK3a_trigger{min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:24px;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:500;line-height:20px;display:flex}.FOsK3a_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}.FOsK3a_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.FOsK3a_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.FOsK3a_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-caption);flex-shrink:1000;overflow:hidden}.FOsK3a_triggerIcon{flex:none;display:none}@container (width<=360px){.FOsK3a_triggerIcon{display:block}.FOsK3a_triggerLabel,.FOsK3a_triggerEffort{display:none}}.FOsK3a_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}.FOsK3a_chevronOpen{transform:rotate(180deg)}.FOsK3a_menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}.FOsK3a_status,.FOsK3a_empty{color:var(--dsw-alias-label-tertiary);padding:10px;font-size:13px;line-height:20px}.FOsK3a_error,.FOsK3a_warning{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:4px;padding:7px 8px;font-size:12px;line-height:18px;display:flex}.FOsK3a_warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}.FOsK3a_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600}.FOsK3a_groups{min-height:0;overflow-y:auto}.FOsK3a_option{box-sizing:border-box;width:auto;min-width:100%;min-height:38px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;border-radius:10px;outline:none;align-items:center;gap:8px;padding:6px 8px;display:flex}.FOsK3a_option:hover:not(:disabled),.FOsK3a_option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_selected{background:0 0}.FOsK3a_option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.FOsK3a_optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}.FOsK3a_modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:500;line-height:20px;overflow:hidden}.FOsK3a_check{color:var(--dsw-alias-label-primary);flex:0 0 18px;place-items:center;display:grid}.FOsK3a_cell{box-sizing:border-box;width:auto;min-width:100%;height:40px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;border-radius:10px;align-items:center;gap:8px;padding:0 10px;font-size:14px;line-height:22px;display:flex}.FOsK3a_cell:hover{background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_cellLabel{white-space:nowrap;flex:none}.FOsK3a_cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}.FOsK3a_cellChevron{color:var(--dsw-alias-label-tertiary);flex:none}";
42
+ const css = ".FOsK3a_root{min-width:0;display:flex;position:relative}.FOsK3a_trigger{min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:24px;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:500;line-height:20px;display:flex}.FOsK3a_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}.FOsK3a_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.FOsK3a_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;display:var(--dsh-composer-model-text-display,block);overflow:hidden}.FOsK3a_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-state-business-primary);display:var(--dsh-composer-model-text-display,block);flex-shrink:1000;overflow:hidden}.FOsK3a_triggerIcon{display:var(--dsh-composer-model-icon-display,none);flex:none}.FOsK3a_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}.FOsK3a_chevronOpen{transform:rotate(180deg)}.FOsK3a_menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-sizing:border-box;width:min(252px,100vw - 24px);max-height:min(70vh,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}.FOsK3a_providerName{text-overflow:ellipsis;white-space:nowrap;min-height:24px;color:var(--dsw-alias-label-tertiary);align-items:center;padding:3px 8px;font-size:12px;font-weight:500;line-height:18px;display:flex;overflow:hidden}.FOsK3a_groups{min-height:0;overflow-y:auto}.FOsK3a_row{align-items:center;gap:2px;display:flex}.FOsK3a_rowPick{box-sizing:border-box;width:100%;min-width:0;min-height:34px;color:inherit;text-align:left;cursor:pointer;padding-inline:6px;background:0 0;border:none;border-radius:10px;outline:none;flex:1;align-items:center;gap:4px;padding:5px 7px;display:flex}.FOsK3a_rowPick:hover:not(:disabled),.FOsK3a_rowPick:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_rowPick:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.FOsK3a_rowCopy{flex-direction:column;flex:1;min-width:0;display:flex}.FOsK3a_rowName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:11.5px;font-weight:400;line-height:17px;overflow:hidden}.FOsK3a_menu .FOsK3a_rowName{font-weight:600}.FOsK3a_rowMeta{color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px;overflow:hidden}.FOsK3a_check{color:var(--dsw-alias-label-primary);flex:0 0 15px;place-items:center;display:grid}.FOsK3a_starStatic{color:var(--dsw-alias-state-warn-primary);flex:none;place-items:center;width:14px;display:grid}.FOsK3a_starButton{color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0;background:0 0;border:none;border-radius:8px;outline:none;flex:none;place-items:center;width:26px;height:26px;display:grid}.FOsK3a_starButton:focus-visible{outline:2px solid var(--dsw-alias-border-l3)}.FOsK3a_row:hover .FOsK3a_starButton,.FOsK3a_row:focus-within .FOsK3a_starButton{opacity:1}.FOsK3a_starButton[aria-pressed=true]{color:var(--dsw-alias-state-warn-primary);opacity:1}@media (hover:none){.FOsK3a_starButton{opacity:1}}.FOsK3a_star{flex:none}.FOsK3a_divider{background:var(--dsw-alias-border-l2);flex:none;height:.5px;margin:3px 2px}.FOsK3a_effortRow{align-items:center;gap:6px;padding:3px 6px;display:flex}.FOsK3a_effortHeading{color:var(--dsw-alias-label-primary);flex:none;font-size:12px;line-height:18px}.FOsK3a_effortTrack{background:var(--dsw-alias-interactive-bg-hover);scrollbar-width:none;border-radius:999px;align-items:center;gap:1px;min-width:0;margin-left:auto;padding:2px;display:flex;overflow-x:auto}.FOsK3a_effortTrack::-webkit-scrollbar{display:none}.FOsK3a_effortSegment{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:999px;outline:none;flex:none;padding:2px 7px;font-size:11px;line-height:16px}.FOsK3a_effortSegment:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.FOsK3a_effortSegment[aria-checked=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-weight:600}.FOsK3a_effortSegment:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.FOsK3a_effortSegment:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:-2px}.FOsK3a_hint{color:var(--dsw-alias-label-tertiary);margin:0;padding:6px 8px;font-size:11px;line-height:16px}.FOsK3a_moreRow{color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:10px;outline:none;align-items:center;gap:6px;min-height:32px;padding:5px 8px;font-size:12px;line-height:18px;display:flex}.FOsK3a_moreRow:hover,.FOsK3a_moreRow:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_moreIcon{color:var(--dsw-alias-label-tertiary);flex:none}.FOsK3a_notice{color:var(--dsw-alias-label-tertiary);justify-content:space-between;align-items:flex-start;gap:8px;padding:6px 10px;font-size:12px;line-height:18px;display:flex}.FOsK3a_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600;text-decoration:underline}.FOsK3a_pickerDialog{gap:0;width:min(680px,100%);height:min(640px,80vh);padding:0}.FOsK3a_pickerContent,.FOsK3a_pickerContent>:last-child{flex-direction:column;flex:1;min-height:0;display:flex}.FOsK3a_pickerSearch{padding:0 20px 12px}.FOsK3a_pickerSearchInput{width:100%}.FOsK3a_pickerTabs{border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap;gap:2px;padding:0 16px;display:flex}.FOsK3a_pickerTab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;outline:none;padding:6px 10px;font-size:13px;line-height:18px}.FOsK3a_pickerTab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.FOsK3a_pickerTab[aria-selected=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);font-weight:600}.FOsK3a_pickerTab:focus-visible{outline:2px solid var(--dsw-alias-border-l3)}.FOsK3a_pickerList{flex:1;min-height:0;padding:8px 12px;overflow-y:auto}.FOsK3a_pickerFooter{border-top:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);align-items:center;gap:6px;padding:10px 20px;font-size:12px;line-height:18px;display:flex}.FOsK3a_footerHint{flex-wrap:wrap;align-items:center;gap:6px;display:flex}.FOsK3a_footerDot{color:var(--dsw-alias-label-dimmed)}";
13
43
  const tagId = "@lynn123411/dsh-model-submenu/ModelSubmenu.module.css";
14
44
  if (typeof document !== "undefined") {
15
45
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]");
@@ -22,31 +52,786 @@ window.__ModuleLoader__.load({
22
52
  tag.textContent = css;
23
53
  }
24
54
  var ModelSubmenu_module_css_default = {
25
- "cell": "FOsK3a_cell",
26
- "cellChevron": "FOsK3a_cellChevron",
27
- "cellLabel": "FOsK3a_cellLabel",
28
- "cellValue": "FOsK3a_cellValue",
29
55
  "check": "FOsK3a_check",
30
56
  "chevron": "FOsK3a_chevron",
31
57
  "chevronOpen": "FOsK3a_chevronOpen",
32
- "empty": "FOsK3a_empty",
33
- "error": "FOsK3a_error",
58
+ "divider": "FOsK3a_divider",
59
+ "effortHeading": "FOsK3a_effortHeading",
60
+ "effortRow": "FOsK3a_effortRow",
61
+ "effortSegment": "FOsK3a_effortSegment",
62
+ "effortTrack": "FOsK3a_effortTrack",
63
+ "footerDot": "FOsK3a_footerDot",
64
+ "footerHint": "FOsK3a_footerHint",
34
65
  "groups": "FOsK3a_groups",
66
+ "hint": "FOsK3a_hint",
35
67
  "menu": "FOsK3a_menu",
36
- "modelName": "FOsK3a_modelName",
37
- "option": "FOsK3a_option",
38
- "optionCopy": "FOsK3a_optionCopy",
68
+ "moreIcon": "FOsK3a_moreIcon",
69
+ "moreRow": "FOsK3a_moreRow",
70
+ "notice": "FOsK3a_notice",
71
+ "pickerContent": "FOsK3a_pickerContent",
72
+ "pickerDialog": "FOsK3a_pickerDialog",
73
+ "pickerFooter": "FOsK3a_pickerFooter",
74
+ "pickerList": "FOsK3a_pickerList",
75
+ "pickerSearch": "FOsK3a_pickerSearch",
76
+ "pickerSearchInput": "FOsK3a_pickerSearchInput",
77
+ "pickerTab": "FOsK3a_pickerTab",
78
+ "pickerTabs": "FOsK3a_pickerTabs",
79
+ "providerName": "FOsK3a_providerName",
39
80
  "retry": "FOsK3a_retry",
40
81
  "root": "FOsK3a_root",
41
- "selected": "FOsK3a_selected",
42
- "status": "FOsK3a_status",
82
+ "row": "FOsK3a_row",
83
+ "rowCopy": "FOsK3a_rowCopy",
84
+ "rowMeta": "FOsK3a_rowMeta",
85
+ "rowName": "FOsK3a_rowName",
86
+ "rowPick": "FOsK3a_rowPick",
87
+ "star": "FOsK3a_star",
88
+ "starButton": "FOsK3a_starButton",
89
+ "starStatic": "FOsK3a_starStatic",
43
90
  "trigger": "FOsK3a_trigger",
44
91
  "triggerEffort": "FOsK3a_triggerEffort",
45
92
  "triggerIcon": "FOsK3a_triggerIcon",
46
- "triggerLabel": "FOsK3a_triggerLabel",
47
- "warning": "FOsK3a_warning"
93
+ "triggerLabel": "FOsK3a_triggerLabel"
48
94
  };
49
95
  //#endregion
96
+ //#region src/client/ModelRow.tsx
97
+ /**
98
+ * Render one model row.
99
+ * @param props - the row's content and behaviour.
100
+ * @returns the row element.
101
+ */
102
+ function ModelRow({ name, selected, busy, star, favorited, meta, starLabel, starTabStop = false, rowTabIndex, onPick, onFocusRow, onToggleFavorite, children }) {
103
+ const copy = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
104
+ className: ModelSubmenu_module_css_default.rowCopy,
105
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
106
+ className: ModelSubmenu_module_css_default.rowName,
107
+ children: name
108
+ }), meta !== void 0 && meta !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
109
+ className: ModelSubmenu_module_css_default.rowMeta,
110
+ children: meta
111
+ })]
112
+ }) });
113
+ const mark = favorited ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StarIcon, {
114
+ filled: true,
115
+ className: ModelSubmenu_module_css_default.star
116
+ }) : null;
117
+ if (star === "static") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
118
+ type: "button",
119
+ role: "menuitemradio",
120
+ "aria-checked": selected,
121
+ tabIndex: rowTabIndex,
122
+ className: ModelSubmenu_module_css_default.rowPick,
123
+ title: name,
124
+ disabled: busy,
125
+ onClick: onPick,
126
+ onFocus: onFocusRow,
127
+ "data-selected": selected ? "" : void 0,
128
+ "data-favorited": favorited ? "" : void 0,
129
+ children: [
130
+ copy,
131
+ mark !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
132
+ className: ModelSubmenu_module_css_default.starStatic,
133
+ "aria-hidden": "true",
134
+ children: mark
135
+ }),
136
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
137
+ className: ModelSubmenu_module_css_default.check,
138
+ "aria-hidden": !selected,
139
+ children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
140
+ }),
141
+ children
142
+ ]
143
+ });
144
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
145
+ className: ModelSubmenu_module_css_default.row,
146
+ "data-selected": selected ? "" : void 0,
147
+ "data-favorited": favorited ? "" : void 0,
148
+ children: [
149
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
150
+ type: "button",
151
+ role: "menuitemradio",
152
+ "aria-checked": selected,
153
+ tabIndex: rowTabIndex,
154
+ className: ModelSubmenu_module_css_default.rowPick,
155
+ title: name,
156
+ disabled: busy,
157
+ onClick: onPick,
158
+ onFocus: onFocusRow,
159
+ children: copy
160
+ }),
161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
162
+ type: "button",
163
+ className: ModelSubmenu_module_css_default.starButton,
164
+ "aria-pressed": favorited,
165
+ "aria-label": starLabel,
166
+ tabIndex: starTabStop ? 0 : -1,
167
+ disabled: busy,
168
+ onClick: onToggleFavorite,
169
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StarIcon, {
170
+ filled: favorited,
171
+ className: ModelSubmenu_module_css_default.star
172
+ })
173
+ }),
174
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
175
+ className: ModelSubmenu_module_css_default.check,
176
+ "aria-hidden": !selected,
177
+ children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
178
+ }),
179
+ children
180
+ ]
181
+ });
182
+ }
183
+ //#endregion
184
+ //#region src/client/favorites.ts
185
+ /** The single browser-wide storage key holding the whole table. */
186
+ const FAVORITES_KEY = "dsh-model-submenu.favorites";
187
+ /**
188
+ * Read the table, keeping only rows that name both halves.
189
+ * @param storage - browser storage, or undefined when the browser withholds it.
190
+ * @returns the stored favorites in stored order; empty when unreadable.
191
+ */
192
+ function readFavorites(storage) {
193
+ const raw = getItem$1(storage);
194
+ if (raw === null) return [];
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw);
198
+ } catch {
199
+ return [];
200
+ }
201
+ if (!Array.isArray(parsed)) return [];
202
+ return parsed.filter((row) => typeof row === "object" && row !== null && typeof row.provider === "string" && typeof row.model === "string");
203
+ }
204
+ /**
205
+ * Add or remove one model, writing the whole table back.
206
+ * @param storage - browser storage, or undefined when writes are unavailable.
207
+ * @param provider - the provider route key.
208
+ * @param model - the model id inside that provider.
209
+ * @returns the table after the change, whether or not the write landed.
210
+ */
211
+ function toggleFavorite(storage, provider, model) {
212
+ const current = readFavorites(storage);
213
+ const next = isFavorite(current, provider, model) ? current.filter((row) => row.provider !== provider || row.model !== model) : [...current, {
214
+ provider,
215
+ model
216
+ }];
217
+ try {
218
+ storage?.setItem(FAVORITES_KEY, JSON.stringify(next));
219
+ } catch {}
220
+ return next;
221
+ }
222
+ /**
223
+ * @param favorites - the table to search.
224
+ * @param provider - the provider route key.
225
+ * @param model - the model id inside that provider.
226
+ * @returns whether that exact pair is favorited.
227
+ */
228
+ function isFavorite(favorites, provider, model) {
229
+ return favorites.some((row) => row.provider === provider && row.model === model);
230
+ }
231
+ /**
232
+ * Stable-partition a catalog so favorited rows lead, each part keeping the
233
+ * catalog's own order.
234
+ * @param rows - the catalog in its display order.
235
+ * @param favorited - predicate answering whether one row is favorited.
236
+ * @returns a new list; the input order when nothing is favorited.
237
+ */
238
+ function orderWithFavorites(rows, favorited) {
239
+ const starred = rows.filter(favorited);
240
+ if (starred.length === 0) return rows;
241
+ return [...starred, ...rows.filter((row) => !favorited(row))];
242
+ }
243
+ function getItem$1(storage) {
244
+ try {
245
+ return storage?.getItem("dsh-model-submenu.favorites") ?? null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+ //#endregion
251
+ //#region src/client/model-facts.ts
252
+ /**
253
+ * The model knowledge a shipped adapter carries for its own account route.
254
+ *
255
+ * The account route has no `models` list on purpose: the account listing
256
+ * supplies the catalog and settings only carry per-model overrides. The same
257
+ * adapter family ships a default catalog for its API-key route, and the account
258
+ * route serves the same models, so those entries answer what the account
259
+ * profile cannot. Ids and facts mirror the adapter's own table.
260
+ */
261
+ const FAMILY_DEFAULTS = { "deepseek-account": [{
262
+ id: "deepseek-flash",
263
+ contextWindow: 1e6,
264
+ images: true
265
+ }, {
266
+ id: "deepseek-v4-pro",
267
+ contextWindow: 1e6,
268
+ images: false
269
+ }] };
270
+ /**
271
+ * Build the fact lookup from the settings answer.
272
+ * @param providers - provider directory rows giving each route's settings address.
273
+ * @param namespaces - the describe answer's namespace views.
274
+ * @returns a lookup answering both fields for one provider and model.
275
+ */
276
+ function buildModelFacts(providers, namespaces) {
277
+ const byName = new Map(namespaces.map((row) => [row.ns, row.value]));
278
+ const profiles = /* @__PURE__ */ new Map();
279
+ for (const row of providers) {
280
+ const profile = readPath(byName.get(row.settingsNs), row.settingsPath);
281
+ if (isRecord(profile)) profiles.set(row.provider, profile);
282
+ }
283
+ return (provider, model) => {
284
+ const profile = profiles.get(provider);
285
+ const entry = profile?.models === void 0 ? void 0 : profile.models.find((candidate) => candidate.id === model);
286
+ if (profile !== void 0 && entry !== void 0) return {
287
+ contextWindow: typeof entry.contextWindow === "number" && entry.contextWindow > 0 ? entry.contextWindow : void 0,
288
+ images: imageAnswer(entry, profile)
289
+ };
290
+ const override = readOverride(profile, model);
291
+ if (override !== void 0) return {
292
+ contextWindow: typeof override.contextWindow === "number" && override.contextWindow > 0 ? override.contextWindow : void 0,
293
+ images: Array.isArray(override.inputModalities) ? containsImage(override.inputModalities) : "unknown"
294
+ };
295
+ const family = FAMILY_DEFAULTS[provider]?.find((candidate) => candidate.id === model);
296
+ return family === void 0 ? {
297
+ contextWindow: void 0,
298
+ images: "unknown"
299
+ } : {
300
+ contextWindow: family.contextWindow,
301
+ images: family.images ? "yes" : "no"
302
+ };
303
+ };
304
+ }
305
+ /**
306
+ * Read one model entry's accepted image modalities.
307
+ *
308
+ * The two adapter families disagree about an omission. The Messages family
309
+ * names the field `inputModalities`, and leaving it out means text only. The
310
+ * pi-ai family names it `input`, and an empty or absent list defers to the
311
+ * route's `defaultInput`; with no route default the answer stays unknown.
312
+ */
313
+ function imageAnswer(entry, profile) {
314
+ if (Array.isArray(entry.inputModalities)) return containsImage(entry.inputModalities);
315
+ const defaultInput = Array.isArray(profile.defaultInput) ? profile.defaultInput : void 0;
316
+ if (Array.isArray(entry.input)) {
317
+ if (entry.input.length > 0) return containsImage(entry.input);
318
+ return defaultInput === void 0 ? "unknown" : containsImage(defaultInput);
319
+ }
320
+ return defaultInput === void 0 ? "no" : containsImage(defaultInput);
321
+ }
322
+ /** One model's declared override, when the route keeps a `modelOverrides` table. */
323
+ function readOverride(profile, model) {
324
+ const overrides = profile?.modelOverrides;
325
+ if (!isRecord(overrides)) return void 0;
326
+ const entry = overrides[model];
327
+ return isRecord(entry) ? entry : void 0;
328
+ }
329
+ function containsImage(modalities) {
330
+ return modalities.includes("image") ? "yes" : "no";
331
+ }
332
+ /**
333
+ * Abbreviate one capacity the way the metadata line shows it.
334
+ * @param tokens - exact advertised capacity in tokens.
335
+ * @returns e.g. `1M ctx`, `1.5M ctx`, `272k ctx`, or `512 ctx` below a thousand.
336
+ */
337
+ function formatContextWindow(tokens) {
338
+ if (tokens >= 1e6) return `${trimNumber(tokens / 1e6)}M ctx`;
339
+ if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k ctx`;
340
+ return `${tokens} ctx`;
341
+ }
342
+ /**
343
+ * Build the metadata line's segments.
344
+ * @param input.providerName - the provider's display name, omitted inside that provider's own tab.
345
+ * @param input.facts - what the settings document answered.
346
+ * @param input.thinks - whether the catalog advertises reasoning levels for this model.
347
+ * @param input.effortLabel - the localized word for the reasoning segment.
348
+ * @param input.imageLabel - the localized word for the image segment.
349
+ * @returns the segments to join; an empty list means show no line.
350
+ */
351
+ function metaSegments(input) {
352
+ const segments = [];
353
+ if (input.providerName !== void 0) segments.push(input.providerName);
354
+ if (input.facts.contextWindow !== void 0) segments.push(formatContextWindow(input.facts.contextWindow));
355
+ if (input.thinks) segments.push(input.effortLabel);
356
+ if (input.facts.images === "yes") segments.push(input.imageLabel);
357
+ return segments;
358
+ }
359
+ function trimNumber(value) {
360
+ const rounded = Math.round(value * 10) / 10;
361
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
362
+ }
363
+ function readPath(value, path) {
364
+ let cursor = value;
365
+ for (const key of path) {
366
+ if (!isRecord(cursor)) return void 0;
367
+ cursor = cursor[key];
368
+ }
369
+ return cursor;
370
+ }
371
+ function isRecord(value) {
372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
373
+ }
374
+ /**
375
+ * Flatten the catalog to one row per model.
376
+ * @param groups - the shared directory's provider groups, in display order.
377
+ * @returns the rows in catalog order.
378
+ */
379
+ function flattenGroups(groups) {
380
+ return groups.flatMap((group) => group.models.map((model) => ({
381
+ provider: group.id,
382
+ providerName: group.name,
383
+ model: model.id,
384
+ entry: model
385
+ })));
386
+ }
387
+ /**
388
+ * Build the picker's tabs: the all-tab, then one per provider that has a model.
389
+ * @param groups - the shared directory's provider groups.
390
+ * @param allLabel - localized label for the all-tab.
391
+ * @returns the tabs in catalog order.
392
+ */
393
+ function pickerTabs(groups, allLabel = "All") {
394
+ return [{
395
+ id: "*",
396
+ label: allLabel
397
+ }, ...groups.filter((group) => group.models.length > 0).map((group) => ({
398
+ id: group.id,
399
+ label: group.name
400
+ }))];
401
+ }
402
+ /**
403
+ * @param rows - the flat catalog.
404
+ * @param tab - the tab id; the all-tab keeps every row.
405
+ * @returns the rows the tab shows, in their current order.
406
+ */
407
+ function rowsForTab(rows, tab) {
408
+ return tab === "*" ? rows : rows.filter((row) => row.provider === tab);
409
+ }
410
+ /**
411
+ * Shape rows as the shell's ranker candidates: the model name is the name, the
412
+ * provider display name is the second key, so a query reaches either.
413
+ * @param rows - the rows to search.
414
+ * @returns candidates in row order.
415
+ */
416
+ function searchCandidates(rows) {
417
+ return rows.map((row) => ({
418
+ name: row.entry.name,
419
+ label: row.providerName,
420
+ row
421
+ }));
422
+ }
423
+ /**
424
+ * Read the reasoning metadata the catalog advertises for one model.
425
+ * @param groups - the shared directory's provider groups.
426
+ * @returns the metadata, or undefined when that model advertises none.
427
+ */
428
+ function reasoningOf(groups) {
429
+ return (provider, model) => groups.find((group) => group.id === provider)?.models.find((candidate) => candidate.id === model)?.reasoning;
430
+ }
431
+ /**
432
+ * Filter rows by a query over the model name and the provider name.
433
+ * @param rows - the rows to search.
434
+ * @param query - the typed text; an empty or blank query keeps every row.
435
+ * @param rank - the shell's own ranker, so this search behaves like every other one.
436
+ * @returns the matching rows.
437
+ */
438
+ function searchRows(rows, query, rank) {
439
+ const trimmed = query.trim();
440
+ if (trimmed === "") return rows;
441
+ return rank(searchCandidates(rows), trimmed).map((match) => match.row);
442
+ }
443
+ //#endregion
444
+ //#region src/client/selection.ts
445
+ /**
446
+ * Build the segments for one model's reasoning row.
447
+ * @param reasoning - the catalog's reasoning metadata, undefined when the model has none.
448
+ * @param providerDefaultLabel - the localized label for the "follow the provider" segment.
449
+ * @returns the segments in catalog order; the provider-default one leads when the model declares no default.
450
+ */
451
+ function effortChoices(reasoning, providerDefaultLabel) {
452
+ if (reasoning === void 0) return [];
453
+ return [...reasoning.defaultEffort === void 0 ? [{
454
+ key: "provider-default",
455
+ effort: void 0,
456
+ label: providerDefaultLabel
457
+ }] : [], ...reasoning.efforts.map((level) => ({
458
+ key: level.id,
459
+ effort: level.id,
460
+ label: level.name
461
+ }))];
462
+ }
463
+ /**
464
+ * The selection a level click submits.
465
+ * @param live - the selection in force right now.
466
+ * @param effort - the clicked level, undefined meaning the provider default.
467
+ * @returns the selection to submit, or null when the level already is the live one.
468
+ */
469
+ function planEffortCommit(live, effort) {
470
+ if (sameEffort(live.reasoningEffort, effort)) return null;
471
+ return {
472
+ provider: live.provider,
473
+ model: live.model,
474
+ ...effort === void 0 ? {} : { reasoningEffort: effort }
475
+ };
476
+ }
477
+ /**
478
+ * The selection a model click submits.
479
+ * @param live - the selection in force right now, null before a first selection resolves.
480
+ * @param provider - the clicked model's provider.
481
+ * @param model - the clicked model id.
482
+ * @param reasoningOf - the catalog's reasoning metadata for one provider and model.
483
+ * @param remembered - the level this browser last used for the target model, if any.
484
+ * @returns the selection to submit, or null when the click changes nothing.
485
+ */
486
+ function planModelCommit(live, provider, model, reasoningOf, remembered) {
487
+ if (live !== null && sameModel(live, provider, model)) return null;
488
+ const offered = reasoningOf(provider, model);
489
+ return {
490
+ provider,
491
+ model,
492
+ ...offered !== void 0 && remembered !== void 0 && offered.efforts.some((level) => level.id === remembered) ? { reasoningEffort: remembered } : {}
493
+ };
494
+ }
495
+ /**
496
+ * @param live - one selection.
497
+ * @param provider - the provider to compare.
498
+ * @param model - the model id to compare.
499
+ * @returns whether both name the same model.
500
+ */
501
+ function sameModel(live, provider, model) {
502
+ return live.provider === provider && live.model === model;
503
+ }
504
+ /**
505
+ * @param liveEffort - the level in force, undefined meaning the provider default.
506
+ * @param effort - the level in question.
507
+ * @returns whether they name the same level.
508
+ */
509
+ function sameEffort(liveEffort, effort) {
510
+ return liveEffort === effort;
511
+ }
512
+ //#endregion
513
+ //#region src/client/ModelPicker.tsx
514
+ /**
515
+ * The model picker: the centered dialog opened from the menu's last row.
516
+ *
517
+ * It is the one place every provider is visible at once — a search field, a tab
518
+ * per provider with a model, and one flat list with each model's metadata line.
519
+ * Favorites lead the list and can be toggled from a row's star without
520
+ * selecting that row. A click commits at once; a rejected commit keeps the
521
+ * dialog open so the user can retry or pick another model.
522
+ */
523
+ /**
524
+ * Render the picker.
525
+ * @param props - the catalog, the live selection, and the picker's verbs.
526
+ * @returns the dialog; nothing while closed.
527
+ */
528
+ function ModelPicker({ open, state, live, facts, favorites, busy, onPick, onToggleFavorite, onReload, onClose, t }) {
529
+ const id = (0, react.useId)();
530
+ const searchRef = (0, react.useRef)(null);
531
+ const listRef = (0, react.useRef)(null);
532
+ const [query, setQuery] = (0, react.useState)("");
533
+ const [tab, setTab] = (0, react.useState)("*");
534
+ const [highlight, setHighlight] = (0, react.useState)(0);
535
+ const tabRefs = (0, react.useRef)(/* @__PURE__ */ new Map());
536
+ const tabs = (0, react.useMemo)(() => pickerTabs(state.groups, t("picker.all")), [state.groups, t]);
537
+ const reason = (0, react.useMemo)(() => reasoningOf(state.groups), [state.groups]);
538
+ const rows = (0, react.useMemo)(() => orderWithFavorites(rowsForTab(flattenGroups(state.groups), tab), (row) => isFavorite(favorites, row.provider, row.model)), [
539
+ state.groups,
540
+ tab,
541
+ favorites
542
+ ]);
543
+ const visible = (0, react.useMemo)(() => orderWithFavorites(searchRows(rows, query, _deepseek_ai_dsh_client_ui_primitives.rankByName), (row) => isFavorite(favorites, row.provider, row.model)), [
544
+ rows,
545
+ query,
546
+ favorites
547
+ ]);
548
+ (0, react.useEffect)(() => {
549
+ if (!open) return;
550
+ setQuery("");
551
+ setTab("*");
552
+ setHighlight(0);
553
+ if (listRef.current !== null) listRef.current.scrollTop = 0;
554
+ const frame = requestAnimationFrame(() => {
555
+ searchRef.current?.focus();
556
+ });
557
+ return () => {
558
+ cancelAnimationFrame(frame);
559
+ };
560
+ }, [open]);
561
+ (0, react.useEffect)(() => {
562
+ setHighlight((current) => visible.length === 0 ? 0 : Math.min(current, visible.length - 1));
563
+ }, [visible.length]);
564
+ (0, react.useEffect)(() => {
565
+ (listRef.current?.querySelector(`[data-row-index="${highlight}"]`))?.scrollIntoView({ block: "nearest" });
566
+ }, [highlight]);
567
+ if (!open) return null;
568
+ const activeRow = visible[highlight];
569
+ const activeId = activeRow === void 0 ? void 0 : `${id}-row-${highlight}`;
570
+ const onSearchKeyDown = (event) => {
571
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
572
+ event.preventDefault();
573
+ if (visible.length === 0) return;
574
+ setHighlight((current) => {
575
+ return (current + (event.key === "ArrowDown" ? 1 : -1) + visible.length) % visible.length;
576
+ });
577
+ return;
578
+ }
579
+ if (event.key === "Enter" && activeRow !== void 0 && !busy) {
580
+ event.preventDefault();
581
+ pick(activeRow);
582
+ }
583
+ };
584
+ const pick = (row) => {
585
+ const selection = planModelCommit(live, row.provider, row.model, reason);
586
+ if (selection === null) {
587
+ onClose();
588
+ return;
589
+ }
590
+ onPick(selection);
591
+ };
592
+ /** Move to another tab and hand it the keyboard, in one step. */
593
+ const goTab = (next) => {
594
+ if (next === void 0 || next.id === tab) return;
595
+ setTab(next.id);
596
+ setHighlight(0);
597
+ tabRefs.current.get(next.id)?.focus();
598
+ };
599
+ const onTabKeyDown = (event) => {
600
+ const index = tabs.findIndex((candidate) => candidate.id === tab);
601
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
602
+ event.preventDefault();
603
+ const step = event.key === "ArrowRight" ? 1 : -1;
604
+ goTab(tabs[((index === -1 ? 0 : index) + step + tabs.length) % tabs.length]);
605
+ return;
606
+ }
607
+ if (event.key === "Home" || event.key === "End") {
608
+ event.preventDefault();
609
+ goTab(tabs[event.key === "Home" ? 0 : tabs.length - 1]);
610
+ }
611
+ };
612
+ const failures = state.failures.filter((failure) => tab === "*" || failure.id === tab);
613
+ const failed = state.error !== null && state.status === "error";
614
+ const loading = state.status === "loading";
615
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
616
+ open,
617
+ onClose,
618
+ title: t("picker.title"),
619
+ closeLabel: t("picker.close"),
620
+ className: ModelSubmenu_module_css_default.pickerDialog ?? "",
621
+ contentClassName: ModelSubmenu_module_css_default.pickerContent ?? "",
622
+ children: [
623
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
624
+ className: ModelSubmenu_module_css_default.pickerSearch,
625
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
626
+ ref: searchRef,
627
+ className: ModelSubmenu_module_css_default.pickerSearchInput ?? "",
628
+ type: "text",
629
+ role: "searchbox",
630
+ "aria-label": t("picker.search"),
631
+ "aria-controls": `${id}-list`,
632
+ "aria-activedescendant": activeId,
633
+ placeholder: t("picker.search"),
634
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutlineRegular, { size: 14 }),
635
+ value: query,
636
+ readOnly: busy,
637
+ onChange: (event) => {
638
+ setQuery(event.target.value);
639
+ },
640
+ onKeyDown: onSearchKeyDown,
641
+ "data-modal-autofocus": ""
642
+ })
643
+ }),
644
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
645
+ className: ModelSubmenu_module_css_default.pickerTabs,
646
+ role: "tablist",
647
+ "aria-label": t("picker.title"),
648
+ children: tabs.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
649
+ ref: (node) => {
650
+ if (node === null) tabRefs.current.delete(entry.id);
651
+ else tabRefs.current.set(entry.id, node);
652
+ },
653
+ type: "button",
654
+ role: "tab",
655
+ id: `${id}-tab-${entry.id}`,
656
+ "aria-selected": entry.id === tab,
657
+ "aria-controls": `${id}-list`,
658
+ tabIndex: 0,
659
+ className: ModelSubmenu_module_css_default.pickerTab,
660
+ onClick: () => {
661
+ setTab(entry.id);
662
+ setHighlight(0);
663
+ },
664
+ onKeyDown: onTabKeyDown,
665
+ onFocus: () => {
666
+ if (entry.id !== tab) {
667
+ setTab(entry.id);
668
+ setHighlight(0);
669
+ }
670
+ },
671
+ children: entry.label
672
+ }, entry.id))
673
+ }),
674
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
675
+ ref: listRef,
676
+ id: `${id}-list`,
677
+ className: ModelSubmenu_module_css_default.pickerList,
678
+ role: "menu",
679
+ "aria-label": t("picker.title"),
680
+ children: [
681
+ loading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
682
+ className: ModelSubmenu_module_css_default.notice,
683
+ children: t("status.loading")
684
+ }),
685
+ failed && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
686
+ className: ModelSubmenu_module_css_default.notice,
687
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("status.failed", { message: state.error ?? "" }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
688
+ type: "button",
689
+ className: ModelSubmenu_module_css_default.retry,
690
+ onClick: onReload,
691
+ children: t("action.reload")
692
+ })]
693
+ }),
694
+ failures.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
695
+ className: ModelSubmenu_module_css_default.notice,
696
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("status.providerFailed", {
697
+ name: failure.name,
698
+ message: failure.message
699
+ }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
700
+ type: "button",
701
+ className: ModelSubmenu_module_css_default.retry,
702
+ onClick: onReload,
703
+ children: t("action.reload")
704
+ })]
705
+ }, failure.id)),
706
+ visible.length === 0 && !loading && !failed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
707
+ className: ModelSubmenu_module_css_default.notice,
708
+ children: state.groups.length === 0 ? t("status.empty") : t("picker.searchEmpty")
709
+ }),
710
+ visible.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
711
+ "data-row-index": index,
712
+ id: `${id}-row-${index}`,
713
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
714
+ name: row.entry.name,
715
+ selected: live?.provider === row.provider && live.model === row.model,
716
+ busy,
717
+ star: "interactive",
718
+ favorited: isFavorite(favorites, row.provider, row.model),
719
+ meta: metaLine({
720
+ row,
721
+ facts: facts(row.provider, row.model),
722
+ providerName: tab === "*" ? row.providerName : void 0,
723
+ thinks: row.entry.reasoning !== void 0,
724
+ effortLabel: t("meta.effort"),
725
+ imageLabel: t("meta.images")
726
+ }),
727
+ starLabel: isFavorite(favorites, row.provider, row.model) ? t("picker.unfavorite", { model: row.entry.name }) : t("picker.favorite", { model: row.entry.name }),
728
+ starTabStop: index === highlight,
729
+ rowTabIndex: -1,
730
+ onPick: () => {
731
+ pick(row);
732
+ },
733
+ onFocusRow: () => {
734
+ setHighlight(index);
735
+ },
736
+ onToggleFavorite: () => {
737
+ onToggleFavorite(row.provider, row.model);
738
+ }
739
+ })
740
+ }, `${row.provider}/${row.model}`))
741
+ ]
742
+ }),
743
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
744
+ className: ModelSubmenu_module_css_default.pickerFooter,
745
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
746
+ className: ModelSubmenu_module_css_default.footerHint,
747
+ children: [
748
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.ShortcutKeys, { keys: ["↑", "↓"] }),
749
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("picker.hintNavigate") }),
750
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
751
+ className: ModelSubmenu_module_css_default.footerDot,
752
+ children: "·"
753
+ }),
754
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.ShortcutKeys, { keys: ["Enter"] }),
755
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("picker.hintSelect") }),
756
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
757
+ className: ModelSubmenu_module_css_default.footerDot,
758
+ children: "·"
759
+ }),
760
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.ShortcutKeys, { keys: ["Esc"] }),
761
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("picker.hintClose") })
762
+ ]
763
+ })
764
+ })
765
+ ]
766
+ });
767
+ }
768
+ function metaLine(input) {
769
+ return metaSegments({
770
+ ...input.providerName === void 0 ? {} : { providerName: input.providerName },
771
+ facts: input.facts,
772
+ thinks: input.thinks,
773
+ effortLabel: input.effortLabel,
774
+ imageLabel: input.imageLabel
775
+ }).join(" · ");
776
+ }
777
+ //#endregion
778
+ //#region src/client/effort-memory.ts
779
+ /** The single browser-wide storage key holding the whole memory. */
780
+ const EFFORTS_KEY = "dsh-model-submenu.efforts";
781
+ /**
782
+ * Read the memory, keeping only rows naming both halves and a level.
783
+ * @param storage - browser storage, or undefined when the browser withholds it.
784
+ * @returns the remembered rows; empty when unreadable.
785
+ */
786
+ function readEfforts(storage) {
787
+ const raw = getItem(storage);
788
+ if (raw === null) return [];
789
+ let parsed;
790
+ try {
791
+ parsed = JSON.parse(raw);
792
+ } catch {
793
+ return [];
794
+ }
795
+ if (!Array.isArray(parsed)) return [];
796
+ return parsed.filter((row) => typeof row === "object" && row !== null && typeof row.provider === "string" && typeof row.model === "string" && typeof row.effort === "string" && row.effort !== "");
797
+ }
798
+ /**
799
+ * @param memory - the rows to search.
800
+ * @param provider - the provider route key.
801
+ * @param model - the model id inside that provider.
802
+ * @returns the level last chosen for that exact model, or undefined.
803
+ */
804
+ function effortFor(memory, provider, model) {
805
+ return memory.find((row) => row.provider === provider && row.model === model)?.effort;
806
+ }
807
+ /**
808
+ * Remember one model's level, or forget it when the choice is the provider default.
809
+ * @param storage - browser storage, or undefined when writes are unavailable.
810
+ * @param provider - the provider route key.
811
+ * @param model - the model id inside that provider.
812
+ * @param effort - the chosen level, undefined meaning the provider default.
813
+ * @returns the memory after the change, whether or not the write landed.
814
+ */
815
+ function rememberEffort(storage, provider, model, effort) {
816
+ const rest = readEfforts(storage).filter((row) => row.provider !== provider || row.model !== model);
817
+ const next = effort === void 0 ? rest : [...rest, {
818
+ provider,
819
+ model,
820
+ effort
821
+ }];
822
+ try {
823
+ storage?.setItem(EFFORTS_KEY, JSON.stringify(next));
824
+ } catch {}
825
+ return next;
826
+ }
827
+ function getItem(storage) {
828
+ try {
829
+ return storage?.getItem("dsh-model-submenu.efforts") ?? null;
830
+ } catch {
831
+ return null;
832
+ }
833
+ }
834
+ //#endregion
50
835
  //#region src/client/ModelSubmenu.tsx
51
836
  /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
52
837
  const MEASURE_STYLE = {
@@ -57,27 +842,26 @@ window.__ModuleLoader__.load({
57
842
  const cls = (...parts) => parts.filter((part) => part !== false && part !== void 0).join(" ");
58
843
  /**
59
844
  * The composer's model seat (`conversation.input.model`), taken over from the
60
- * built-in occupant with a provider level: the root pane keeps the built-in's
61
- * two cells — 供应商 in place of 模型, plus the native 推理等级 — the provider
62
- * cell drills into the catalog's providers, and a provider drills into its
63
- * models before submitting. The trigger, the menu surface, the keyboard
64
- * contract (arrows move, Tab settles, Escape and Shift+Tab leave a drilled pane
65
- * first), the effort list and the failure surfaces are the built-in ones.
66
- */
67
- function ModelSubmenu({ locked, available, directory, load, select, t }) {
845
+ * built-in occupant with a provider-scoped menu: one provider's models above,
846
+ * the reasoning row as segments, the cache warning, and the door to the picker
847
+ * that lists every provider.
848
+ *
849
+ * Every click commits at once: a model, a reasoning level, or the picker door.
850
+ * Closing the menu never writes anything, and the picker reads the level the
851
+ * Host already accepted, so its "current" reading is never stale.
852
+ */
853
+ function ModelSubmenu({ locked, available, directory, load, select, t, factsSource }) {
68
854
  const state = (0, react.useSyncExternalStore)((fn) => directory.subscribe(fn), () => directory.getSnapshot());
69
855
  const [open, setOpen] = (0, react.useState)(false);
70
- const [pane, setPane] = (0, react.useState)("root");
71
- const [providerId, setProviderId] = (0, react.useState)(null);
72
- const lastActionRef = (0, react.useRef)("load");
856
+ const [pickerOpen, setPickerOpen] = (0, react.useState)(false);
857
+ const [favorites, setFavorites] = (0, react.useState)(() => readFavorites(storageOf()));
858
+ const [memory, setMemory] = (0, react.useState)(() => readEfforts(storageOf()));
73
859
  const [toast, setToast] = (0, react.useState)(null);
74
860
  const toastSeq = (0, react.useRef)(0);
75
861
  const rootRef = (0, react.useRef)(null);
76
862
  const triggerRef = (0, react.useRef)(null);
77
863
  const menuRef = (0, react.useRef)(null);
78
864
  const [menuPos, setMenuPos] = (0, react.useState)(null);
79
- const itemRefs = (0, react.useRef)(/* @__PURE__ */ new Map());
80
- const focusIntent = (0, react.useRef)(null);
81
865
  const id = (0, react.useId)();
82
866
  const groups = state.groups;
83
867
  const current = state.current;
@@ -85,47 +869,71 @@ window.__ModuleLoader__.load({
85
869
  const currentModel = currentGroup?.models.find((model) => model.id === current?.model);
86
870
  const reasoning = currentModel?.reasoning;
87
871
  const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort;
88
- const effortLabel = reasoning === void 0 ? void 0 : effectiveEffort === void 0 ? t("effort.providerDefault") : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort;
89
- const effortChoices = (0, react.useMemo)(() => reasoning === void 0 ? [] : [...reasoning.defaultEffort === void 0 ? [{
90
- key: "provider-default",
91
- effort: void 0,
92
- label: t("effort.providerDefault")
93
- }] : [], ...reasoning.efforts.map((level) => ({
94
- key: `effort:${level.id}`,
95
- effort: level.id,
96
- label: level.name
97
- }))], [reasoning, t]);
98
- const shownGroup = pane === "models" && providerId !== null ? groups.find((group) => group.id === providerId) : void 0;
872
+ /**
873
+ * The trigger's caption. A model that advertises no levels still says so in
874
+ * the same place: the blue caption beside the name reads 默认 rather than
875
+ * disappearing, so the shape of the control never changes with the model.
876
+ */
877
+ const effortLabel = reasoning === void 0 || effectiveEffort === void 0 ? t("effort.providerDefault") : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort;
878
+ const choices = (0, react.useMemo)(() => effortChoices(reasoning, t("effort.providerDefault")), [reasoning, t]);
99
879
  const busy = state.status === "selecting";
880
+ const live = (0, react.useMemo)(() => selectionOf(state), [state]);
881
+ const reason = (0, react.useMemo)(() => reasoningOf(groups), [groups]);
882
+ const facts = (0, react.useSyncExternalStore)((fn) => factsSource.subscribe(fn), () => factsSource.getSnapshot());
883
+ const menuRows = (0, react.useMemo)(() => currentGroup === void 0 ? [] : orderWithFavorites(currentGroup.models, (model) => isFavorite(favorites, currentGroup.id, model.id)), [currentGroup, favorites]);
884
+ const raiseToast = (text) => {
885
+ toastSeq.current += 1;
886
+ setToast({
887
+ seq: toastSeq.current,
888
+ text
889
+ });
890
+ };
100
891
  const reload = () => {
101
- lastActionRef.current = "load";
102
892
  load();
103
893
  };
894
+ const submit = (selection, onDone, onRejected) => {
895
+ select(selection).then((result) => {
896
+ if (result === void 0 || result.ok) {
897
+ remember(selection.provider, selection.model, selection.reasoningEffort);
898
+ onDone?.();
899
+ return;
900
+ }
901
+ onRejected?.();
902
+ raiseToast(result.error.code === "session/writer-held" ? t("error.sessionInUse") : t("error.action", { message: `${result.error.code}: ${result.error.message}` }));
903
+ });
904
+ };
905
+ /** Close the menu. Closing writes nothing: every choice already committed on click. */
906
+ const closeMenu = (0, react.useCallback)((restoreFocus) => {
907
+ setOpen(false);
908
+ if (restoreFocus) queueMicrotask(() => {
909
+ triggerRef.current?.focus();
910
+ });
911
+ }, []);
104
912
  (0, react.useEffect)(() => {
105
913
  if (!open) return;
106
914
  const closeOutside = (event) => {
107
915
  if (rootRef.current?.contains(event.target) === true) return;
108
916
  if (menuRef.current?.contains(event.target) === true) return;
109
- setOpen(false);
110
- setPane("root");
917
+ closeMenu(false);
111
918
  };
112
919
  document.addEventListener("mousedown", closeOutside);
113
920
  return () => {
114
921
  document.removeEventListener("mousedown", closeOutside);
115
922
  };
116
- }, [open]);
923
+ }, [open, closeMenu]);
117
924
  (0, react.useEffect)(() => {
118
- const intent = focusIntent.current;
119
- focusIntent.current = null;
120
- if (!open || intent === null) return;
121
- if (intent === "initial") {
122
- const checked = menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled]), [role=\"menuitem\"][aria-current=\"true\"]:not([disabled])");
123
- const first = [...itemRefs.current].find(([key]) => !key.startsWith("back:"))?.[1];
124
- (checked ?? first ?? triggerRef.current)?.focus();
125
- return;
126
- }
127
- (itemRefs.current.get(intent) ?? triggerRef.current)?.focus();
128
- }, [open, pane]);
925
+ if (!open) return;
926
+ const frame = requestAnimationFrame(() => {
927
+ const checked = menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])");
928
+ const first = menuRef.current?.querySelector("[role=\"menuitemradio\"]:not([disabled])");
929
+ const target = checked ?? first ?? triggerRef.current;
930
+ target?.scrollIntoView({ block: "nearest" });
931
+ target?.focus();
932
+ });
933
+ return () => {
934
+ cancelAnimationFrame(frame);
935
+ };
936
+ }, [open]);
129
937
  (0, react.useLayoutEffect)(() => {
130
938
  if (!open) {
131
939
  setMenuPos(null);
@@ -153,63 +961,72 @@ window.__ModuleLoader__.load({
153
961
  window.removeEventListener("scroll", place, true);
154
962
  window.removeEventListener("resize", place);
155
963
  };
156
- }, [
157
- open,
158
- pane,
159
- state
160
- ]);
964
+ }, [open, state]);
161
965
  if (!available) return null;
162
966
  const show = () => {
163
- setPane("root");
164
- setProviderId(null);
165
967
  setOpen(true);
166
968
  reload();
167
969
  };
168
- const close = (restoreFocus = false) => {
970
+ /** The picker door: the level already landed on click, so only the surface changes. */
971
+ const openPicker = () => {
169
972
  setOpen(false);
170
- setPane("root");
171
- setProviderId(null);
172
- if (restoreFocus) queueMicrotask(() => {
173
- triggerRef.current?.focus();
174
- });
973
+ setPickerOpen(true);
175
974
  };
176
- const drill = (next) => {
177
- focusIntent.current = "initial";
178
- setPane(next);
975
+ /** Record one model's level, so switching away and back restores it. */
976
+ const remember = (provider, model, effort) => {
977
+ setMemory(rememberEffort(storageOf(), provider, model, effort));
179
978
  };
180
- /** Leave the shown pane for its parent, handing the keyboard back to the row that opened it. */
181
- const back = () => {
182
- if (pane === "models") {
183
- focusIntent.current = providerId === null ? null : `provider:${providerId}`;
184
- setPane("providers");
979
+ const chooseModel = (provider, model) => {
980
+ const picked = planModelCommit(live, provider, model.id, reason, effortFor(memory, provider, model.id));
981
+ if (picked === null) {
982
+ setOpen(false);
983
+ queueMicrotask(() => {
984
+ triggerRef.current?.focus();
985
+ });
185
986
  return;
186
987
  }
187
- focusIntent.current = pane === "effort" ? "root:effort" : "root:provider";
188
- setPane("root");
988
+ setOpen(false);
989
+ submit(picked, () => {
990
+ queueMicrotask(() => {
991
+ triggerRef.current?.focus();
992
+ });
993
+ }, () => {
994
+ setOpen(true);
995
+ });
189
996
  };
997
+ const rowButtons = () => [...menuRef.current?.querySelectorAll("[role=\"menuitemradio\"]:not([disabled])") ?? []];
190
998
  const moveFocus = (offset) => {
191
- const items = [...itemRefs.current.values()];
999
+ const items = rowButtons();
192
1000
  if (items.length === 0) return;
193
1001
  const active = items.findIndex((item) => item === document.activeElement);
194
1002
  items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus();
195
1003
  };
1004
+ const onEffortKeyDown = (event) => {
1005
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1006
+ if (choices.length === 0 || live === null) return;
1007
+ event.preventDefault();
1008
+ const index = choices.findIndex((choice) => choice.effort === effectiveEffort);
1009
+ const step = event.key === "ArrowRight" ? 1 : -1;
1010
+ const next = choices[((index === -1 ? 0 : index) + step + choices.length) % choices.length];
1011
+ if (next === void 0) return;
1012
+ const pending = planEffortCommit(live, next.effort);
1013
+ if (pending !== null) submit(pending, null);
1014
+ };
196
1015
  const onRootKeyDown = (event) => {
197
1016
  if (event.key === "Escape" && open) {
198
1017
  event.preventDefault();
199
- if (pane !== "root") back();
200
- else close(true);
1018
+ closeMenu(true);
201
1019
  return;
202
1020
  }
203
1021
  if (!open) return;
204
1022
  if (event.key === "Tab") {
205
1023
  if (event.shiftKey) {
206
1024
  event.preventDefault();
207
- if (pane !== "root") back();
208
- else close(true);
1025
+ closeMenu(true);
209
1026
  return;
210
1027
  }
211
1028
  const focused = document.activeElement;
212
- const rows = [...itemRefs.current.values()];
1029
+ const rows = rowButtons();
213
1030
  if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
214
1031
  event.preventDefault();
215
1032
  focused.click();
@@ -217,7 +1034,7 @@ window.__ModuleLoader__.load({
217
1034
  }
218
1035
  if (focused !== triggerRef.current) return;
219
1036
  event.preventDefault();
220
- (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled]), [role=\"menuitem\"][aria-current=\"true\"]:not([disabled])") ?? rows.find((item) => !item.disabled))?.focus();
1037
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? rows[0])?.focus();
221
1038
  return;
222
1039
  }
223
1040
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
@@ -225,73 +1042,28 @@ window.__ModuleLoader__.load({
225
1042
  moveFocus(event.key === "ArrowDown" ? 1 : -1);
226
1043
  }
227
1044
  };
1045
+ /**
1046
+ * The keyboard leaving the seat closes the menu — but that is not one of the
1047
+ * four closing gestures that write a mark, so it closes without committing.
1048
+ * Otherwise clicking the notice text or a divider inside the menu, which moves
1049
+ * focus without unmounting it, would land a level nobody asked to commit.
1050
+ */
228
1051
  const onBlur = (event) => {
1052
+ if (pickerOpen) return;
229
1053
  if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return;
230
- close();
231
- };
232
- const settleSelection = (result) => {
233
- if (result === void 0) return;
234
- if (result.ok) {
235
- if (rootRef.current !== null) close(true);
236
- return;
237
- }
238
- const { error } = result;
239
- toastSeq.current += 1;
240
- setToast({
241
- seq: toastSeq.current,
242
- text: error.code === "session/writer-held" ? t("error.sessionInUse") : t("error.action", { message: `${error.code}: ${error.message}` })
243
- });
244
- };
245
- const submit = (selection) => {
246
- lastActionRef.current = "select";
247
- select(selection).then(settleSelection);
248
- };
249
- /** A model commits outright; its reasoning levels stay behind the root's effort cell. */
250
- const chooseModel = (provider, model) => {
251
- if (current?.provider === provider && current.model === model.id) {
252
- close(true);
253
- return;
254
- }
255
- submit({
256
- provider,
257
- model: model.id
258
- });
1054
+ setOpen(false);
259
1055
  };
260
- const chooseEffort = (effort) => {
261
- if (current === null) return;
262
- if (effectiveEffort === effort) {
263
- close(true);
264
- return;
265
- }
266
- submit({
267
- provider: current.provider,
268
- model: current.model,
269
- ...effort === void 0 ? {} : { reasoningEffort: effort }
270
- });
1056
+ const toggleFavoriteOf = (provider, model) => {
1057
+ setFavorites(toggleFavorite(storageOf(), provider, model));
271
1058
  };
272
1059
  const waiting = current === null && state.status === "loading";
273
1060
  const modelLabel = waiting ? t("trigger.loading") : currentModel?.name ?? (current === null ? t("trigger.fallback") : `${current.provider}/${current.model}`);
274
- const triggerLabel = effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`;
275
1061
  const triggerAria = waiting ? t("trigger.loading") : current === null ? t("trigger.selectAria") : effortLabel === void 0 ? t("trigger.aria", { model: modelLabel }) : t("trigger.ariaEffort", {
276
1062
  model: modelLabel,
277
1063
  effort: effortLabel
278
1064
  });
279
- itemRefs.current = /* @__PURE__ */ new Map();
280
- const itemRef = (key) => (node) => {
281
- if (node === null) itemRefs.current.delete(key);
282
- else itemRefs.current.set(key, node);
283
- };
284
- const backRow = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
285
- ref: itemRef("back"),
286
- type: "button",
287
- role: "menuitem",
288
- className: ModelSubmenu_module_css_default.cell,
289
- onClick: back,
290
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronLeftOutlineRegular, { className: ModelSubmenu_module_css_default.cellChevron }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
291
- className: ModelSubmenu_module_css_default.cellLabel,
292
- children: t("back")
293
- })]
294
- });
1065
+ const providerName = currentGroup?.name ?? state.failures.find((failure) => failure.id === current?.provider)?.name ?? current?.provider ?? "";
1066
+ const providerFailure = current === null ? void 0 : state.failures.find((failure) => failure.id === current.provider);
295
1067
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
296
1068
  ref: rootRef,
297
1069
  className: ModelSubmenu_module_css_default.root,
@@ -306,14 +1078,14 @@ window.__ModuleLoader__.load({
306
1078
  "aria-haspopup": "menu",
307
1079
  "aria-expanded": open,
308
1080
  "aria-controls": open ? `${id}-menu` : void 0,
309
- title: triggerLabel,
1081
+ title: effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`,
310
1082
  disabled: locked,
311
1083
  onClick: () => {
312
- if (open) close();
1084
+ if (open) closeMenu(false);
313
1085
  else show();
314
1086
  },
315
1087
  children: [
316
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular, {
1088
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFlatListOutlineRegular, {
317
1089
  className: ModelSubmenu_module_css_default.triggerIcon,
318
1090
  size: 16
319
1091
  }),
@@ -333,171 +1105,127 @@ window.__ModuleLoader__.load({
333
1105
  id: `${id}-menu`,
334
1106
  className: ModelSubmenu_module_css_default.menu,
335
1107
  style: menuPos ?? MEASURE_STYLE,
336
- role: "menu",
337
- "aria-label": pane === "models" && shownGroup !== void 0 ? shownGroup.name : pane === "effort" ? t("menu.effort") : t("menu.aria"),
1108
+ role: "group",
1109
+ "aria-label": t("menu.aria"),
338
1110
  "aria-busy": state.status === "loading" || busy,
339
1111
  children: [
340
- pane === "root" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
341
- ref: itemRef("root:provider"),
342
- type: "button",
343
- role: "menuitem",
344
- className: ModelSubmenu_module_css_default.cell,
345
- onClick: () => drill("providers"),
346
- children: [
347
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
348
- className: ModelSubmenu_module_css_default.cellLabel,
349
- children: t("menu.provider")
350
- }),
351
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
352
- className: ModelSubmenu_module_css_default.cellValue,
353
- children: currentGroup?.name ?? current?.provider ?? ""
354
- }),
355
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: ModelSubmenu_module_css_default.cellChevron })
356
- ]
357
- }), reasoning !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
358
- ref: itemRef("root:effort"),
359
- type: "button",
360
- role: "menuitem",
361
- className: ModelSubmenu_module_css_default.cell,
362
- onClick: () => drill("effort"),
363
- children: [
364
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
365
- className: ModelSubmenu_module_css_default.cellLabel,
1112
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1113
+ className: ModelSubmenu_module_css_default.providerName,
1114
+ title: providerName,
1115
+ children: providerName
1116
+ }),
1117
+ state.status === "loading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1118
+ className: ModelSubmenu_module_css_default.notice,
1119
+ children: t("status.loading")
1120
+ }),
1121
+ state.error !== null && state.status === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1122
+ className: ModelSubmenu_module_css_default.notice,
1123
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("status.failed", { message: state.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1124
+ type: "button",
1125
+ className: ModelSubmenu_module_css_default.retry,
1126
+ onClick: reload,
1127
+ children: t("action.reload")
1128
+ })]
1129
+ }),
1130
+ providerFailure !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1131
+ className: ModelSubmenu_module_css_default.notice,
1132
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("status.providerFailed", {
1133
+ name: providerFailure.name,
1134
+ message: providerFailure.message
1135
+ }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1136
+ type: "button",
1137
+ className: ModelSubmenu_module_css_default.retry,
1138
+ onClick: reload,
1139
+ children: t("action.reload")
1140
+ })]
1141
+ }),
1142
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1143
+ className: cls(ModelSubmenu_module_css_default.groups, "scrollable"),
1144
+ role: "menu",
1145
+ "aria-label": t("menu.aria"),
1146
+ children: [menuRows.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
1147
+ name: model.name,
1148
+ selected: current !== null && currentGroup !== void 0 && current.provider === currentGroup.id && current.model === model.id,
1149
+ busy,
1150
+ star: "static",
1151
+ favorited: currentGroup !== void 0 && isFavorite(favorites, currentGroup.id, model.id),
1152
+ onPick: () => {
1153
+ if (currentGroup !== void 0) chooseModel(currentGroup.id, model);
1154
+ }
1155
+ }, model.id)), menuRows.length === 0 && state.status === "ready" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1156
+ className: ModelSubmenu_module_css_default.notice,
1157
+ children: t("status.empty")
1158
+ })]
1159
+ }),
1160
+ choices.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: ModelSubmenu_module_css_default.divider }),
1162
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1163
+ className: ModelSubmenu_module_css_default.effortRow,
1164
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1165
+ className: ModelSubmenu_module_css_default.effortHeading,
366
1166
  children: t("menu.effort")
367
- }),
368
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
369
- className: ModelSubmenu_module_css_default.cellValue,
370
- children: effortLabel
371
- }),
372
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: ModelSubmenu_module_css_default.cellChevron })
373
- ]
374
- })] }),
375
- pane === "providers" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
376
- state.status === "loading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
377
- className: ModelSubmenu_module_css_default.status,
378
- children: t("status.loading")
379
- }),
380
- state.error !== null && lastActionRef.current === "load" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
381
- className: ModelSubmenu_module_css_default.error,
382
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("error.action", { message: state.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
383
- type: "button",
384
- className: ModelSubmenu_module_css_default.retry,
385
- onClick: reload,
386
- children: t("action.reload")
387
- })]
388
- }),
389
- state.failures.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
390
- className: ModelSubmenu_module_css_default.warning,
391
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("warning.groupLoad", {
392
- name: failure.name,
393
- message: failure.message
394
- }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
395
- type: "button",
396
- className: ModelSubmenu_module_css_default.retry,
397
- onClick: reload,
398
- children: t("action.reload")
399
- })]
400
- }, failure.id)),
401
- state.status === "ready" && groups.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
402
- className: ModelSubmenu_module_css_default.empty,
403
- children: t("empty.models")
404
- }),
405
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
406
- className: cls(ModelSubmenu_module_css_default.groups, "scrollable"),
407
- children: groups.map((group) => {
408
- const mine = current?.provider === group.id;
409
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
410
- ref: itemRef(`provider:${group.id}`),
1167
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1168
+ className: ModelSubmenu_module_css_default.effortTrack,
1169
+ role: "radiogroup",
1170
+ "aria-label": t("menu.effort"),
1171
+ onKeyDown: onEffortKeyDown,
1172
+ children: choices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
411
1173
  type: "button",
412
- role: "menuitem",
413
- "aria-current": mine ? "true" : void 0,
414
- className: ModelSubmenu_module_css_default.cell,
415
- title: group.name,
1174
+ role: "radio",
1175
+ "aria-checked": choice.effort === effectiveEffort,
1176
+ className: ModelSubmenu_module_css_default.effortSegment,
1177
+ disabled: busy,
416
1178
  onClick: () => {
417
- setProviderId(group.id);
418
- drill("models");
1179
+ if (live !== null) {
1180
+ const pending = planEffortCommit(live, choice.effort);
1181
+ if (pending !== null) submit(pending, null);
1182
+ }
419
1183
  },
420
- children: [
421
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
422
- className: ModelSubmenu_module_css_default.cellLabel,
423
- children: group.name
424
- }),
425
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
426
- className: ModelSubmenu_module_css_default.cellValue,
427
- children: mine ? currentModel?.name ?? current?.model ?? "" : t("provider.modelCount", { count: String(group.models.length) })
428
- }),
429
- mine && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
430
- className: ModelSubmenu_module_css_default.check,
431
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
432
- }),
433
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: ModelSubmenu_module_css_default.cellChevron })
434
- ]
435
- }, group.id);
436
- })
437
- })
438
- ] }),
439
- pane === "models" && shownGroup !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [backRow(), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
440
- className: cls(ModelSubmenu_module_css_default.groups, "scrollable"),
441
- children: shownGroup.models.map((model) => {
442
- const selected = current?.provider === shownGroup.id && current.model === model.id;
443
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
444
- ref: itemRef(`model:${shownGroup.id}/${model.id}`),
445
- type: "button",
446
- role: "menuitemradio",
447
- "aria-checked": selected,
448
- className: cls(ModelSubmenu_module_css_default.option, selected && ModelSubmenu_module_css_default.selected),
449
- title: model.name,
450
- disabled: busy,
451
- onClick: () => chooseModel(shownGroup.id, model),
452
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
453
- className: ModelSubmenu_module_css_default.optionCopy,
454
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
455
- className: ModelSubmenu_module_css_default.modelName,
456
- children: model.name
457
- })
458
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
459
- className: ModelSubmenu_module_css_default.check,
460
- children: selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
461
- })]
462
- }, model.id);
463
- })
464
- })] }),
465
- pane === "effort" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
466
- backRow(),
467
- state.error !== null && lastActionRef.current === "load" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
468
- className: ModelSubmenu_module_css_default.error,
469
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("error.action", { message: state.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
470
- type: "button",
471
- className: ModelSubmenu_module_css_default.retry,
472
- onClick: reload,
473
- children: t("action.reload")
1184
+ children: choice.label
1185
+ }, choice.key))
474
1186
  })]
475
1187
  }),
476
- effortChoices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
477
- className: ModelSubmenu_module_css_default.empty,
478
- children: t("empty.efforts")
479
- }) : effortChoices.map((level) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
480
- ref: itemRef(`effort:${level.key}`),
481
- type: "button",
482
- role: "menuitemradio",
483
- "aria-checked": effectiveEffort === level.effort,
484
- className: cls(ModelSubmenu_module_css_default.option, effectiveEffort === level.effort && ModelSubmenu_module_css_default.selected),
485
- disabled: busy,
486
- onClick: () => chooseEffort(level.effort),
487
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
488
- className: ModelSubmenu_module_css_default.optionCopy,
489
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
490
- className: ModelSubmenu_module_css_default.modelName,
491
- children: level.label
492
- })
493
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
494
- className: ModelSubmenu_module_css_default.check,
495
- children: effectiveEffort === level.effort ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
496
- })]
497
- }, level.key))
498
- ] })
1188
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: ModelSubmenu_module_css_default.divider })
1189
+ ] }),
1190
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1191
+ className: ModelSubmenu_module_css_default.hint,
1192
+ children: t("menu.hint")
1193
+ }),
1194
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: ModelSubmenu_module_css_default.divider }),
1195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1196
+ type: "button",
1197
+ role: "menuitem",
1198
+ className: ModelSubmenu_module_css_default.moreRow,
1199
+ onClick: openPicker,
1200
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFlatListOutlineRegular, {
1201
+ className: ModelSubmenu_module_css_default.moreIcon,
1202
+ size: 16
1203
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("menu.more") })]
1204
+ })
499
1205
  ]
500
1206
  }), document.body),
1207
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelPicker, {
1208
+ open: pickerOpen,
1209
+ state,
1210
+ live,
1211
+ facts,
1212
+ favorites,
1213
+ busy,
1214
+ t,
1215
+ onPick: (selection) => {
1216
+ submit(selection, () => {
1217
+ setPickerOpen(false);
1218
+ });
1219
+ },
1220
+ onToggleFavorite: toggleFavoriteOf,
1221
+ onReload: reload,
1222
+ onClose: () => {
1223
+ setPickerOpen(false);
1224
+ queueMicrotask(() => {
1225
+ triggerRef.current?.focus();
1226
+ });
1227
+ }
1228
+ }),
501
1229
  toast !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Toast, {
502
1230
  text: toast.text,
503
1231
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutlineRegular, {}),
@@ -509,67 +1237,160 @@ window.__ModuleLoader__.load({
509
1237
  ]
510
1238
  });
511
1239
  }
1240
+ /** The selection in force, or null while the host has not resolved one. */
1241
+ function selectionOf(state) {
1242
+ if (state.current === null) return null;
1243
+ return state.current.reasoningEffort === void 0 ? {
1244
+ provider: state.current.provider,
1245
+ model: state.current.model
1246
+ } : {
1247
+ provider: state.current.provider,
1248
+ model: state.current.model,
1249
+ reasoningEffort: state.current.reasoningEffort
1250
+ };
1251
+ }
1252
+ /** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
1253
+ function storageOf() {
1254
+ try {
1255
+ return globalThis.localStorage;
1256
+ } catch {
1257
+ return;
1258
+ }
1259
+ }
1260
+ //#endregion
1261
+ //#region src/client/facts-source.ts
1262
+ const EMPTY = () => ({
1263
+ contextWindow: void 0,
1264
+ images: "unknown"
1265
+ });
1266
+ /**
1267
+ * Join the settings mirror with the provider directory.
1268
+ * @param deps.describe - the settings mirror's describe face.
1269
+ * @param deps.listProviders - reads the LLM provider directory once.
1270
+ * @returns the source, plus `start` which wires both reads and returns its disposer.
1271
+ */
1272
+ function createFactsSource(deps) {
1273
+ const listeners = /* @__PURE__ */ new Set();
1274
+ let providers = [];
1275
+ let lookup = EMPTY;
1276
+ const rebuild = () => {
1277
+ const namespaces = deps.describe.getSnapshot().view?.namespaces ?? [];
1278
+ lookup = providers.length === 0 || namespaces.length === 0 ? EMPTY : buildModelFacts(providers, namespaces);
1279
+ for (const listener of [...listeners]) listener();
1280
+ };
1281
+ return {
1282
+ source: {
1283
+ getSnapshot: () => lookup,
1284
+ subscribe: (listener) => {
1285
+ listeners.add(listener);
1286
+ return () => {
1287
+ listeners.delete(listener);
1288
+ };
1289
+ }
1290
+ },
1291
+ start: () => {
1292
+ const off = deps.describe.subscribe(rebuild);
1293
+ deps.describe.ensure().catch(() => {});
1294
+ deps.listProviders().then((answer) => {
1295
+ providers = answer.ok && answer.value !== void 0 ? answer.value : [];
1296
+ rebuild();
1297
+ }, () => {});
1298
+ rebuild();
1299
+ return () => {
1300
+ off();
1301
+ listeners.clear();
1302
+ };
1303
+ }
1304
+ };
1305
+ }
512
1306
  //#endregion
513
1307
  //#region src/client/locales.ts
514
- /** Locale bundles for the composer model seat this plugin takes over. */
1308
+ /** Locale bundles for the composer model menu and the picker it opens. */
515
1309
  /** Dictionary namespace owned by this plugin. */
516
1310
  const NS = "modelSubmenu";
517
1311
  /** Simplified Chinese dictionary (the key-set source of truth). */
518
1312
  const zh = {
519
- "back": "返回",
520
- "provider.modelCount": "{count} 个模型",
521
- "action.reload": "重新加载",
522
1313
  "trigger.fallback": "选择模型",
523
1314
  "trigger.loading": "正在加载模型…",
524
1315
  "trigger.selectAria": "选择模型",
525
1316
  "trigger.aria": "选择模型,当前 {model}",
526
- "trigger.ariaEffort": "选择模型,当前 {model},推理等级 {effort}",
527
- "menu.aria": "供应商与推理等级",
528
- "menu.provider": "供应商",
529
- "menu.effort": "推理等级",
530
- "effort.providerDefault": "Default",
531
- "status.loading": "正在刷新模型列表…",
1317
+ "trigger.ariaEffort": "选择模型,当前 {model},思考等级 {effort}",
1318
+ "menu.aria": "模型与思考等级",
1319
+ "menu.effort": "思考",
1320
+ "menu.more": "更多模型…",
1321
+ "menu.hint": "提示:切换模型或思考程度会使既有的提示词缓存失效。建议新建会话,避免额外的 token 消耗。",
1322
+ "effort.providerDefault": "默认",
1323
+ "status.loading": "正在加载模型…",
1324
+ "status.empty": "没有可用的模型。",
1325
+ "status.failed": "模型列表加载失败:{message}",
1326
+ "status.providerFailed": "{name} 加载失败:{message}",
1327
+ "action.reload": "重新加载",
532
1328
  "error.action": "模型操作失败:{message}",
533
1329
  "error.sessionInUse": "当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。",
534
- "warning.groupLoad": "{name} 加载失败:{message}",
535
- "empty.models": "没有可用的模型。",
536
- "empty.efforts": "当前模型未提供推理等级。"
1330
+ "picker.title": "切换模型",
1331
+ "picker.close": "关闭",
1332
+ "picker.search": "搜索模型或提供商…",
1333
+ "picker.searchEmpty": "没有匹配的模型。",
1334
+ "picker.all": "全部",
1335
+ "picker.favorite": "收藏 {model}",
1336
+ "picker.unfavorite": "取消收藏 {model}",
1337
+ "picker.hintNavigate": "导航",
1338
+ "picker.hintSelect": "选择",
1339
+ "picker.hintClose": "关闭",
1340
+ "meta.effort": "思考",
1341
+ "meta.images": "图片输入"
537
1342
  };
538
1343
  /** English dictionary, checked complete against the zh key set. */
539
1344
  const en = {
540
- "back": "Back",
541
- "provider.modelCount": "{count} models",
542
- "action.reload": "Reload",
543
1345
  "trigger.fallback": "Select model",
544
1346
  "trigger.loading": "Loading models…",
545
1347
  "trigger.selectAria": "Select model",
546
1348
  "trigger.aria": "Select model, current {model}",
547
- "trigger.ariaEffort": "Select model, current {model}, reasoning effort {effort}",
548
- "menu.aria": "Provider and reasoning effort",
549
- "menu.provider": "Provider",
1349
+ "trigger.ariaEffort": "Select model, current {model}, effort {effort}",
1350
+ "menu.aria": "Model and effort",
550
1351
  "menu.effort": "Effort",
1352
+ "menu.more": "More models…",
1353
+ "menu.hint": "Changing the model or the effort invalidates the existing prompt cache. Start a new session to avoid the extra token cost.",
551
1354
  "effort.providerDefault": "Default",
552
- "status.loading": "Refreshing model list…",
1355
+ "status.loading": "Loading models…",
1356
+ "status.empty": "No models available.",
1357
+ "status.failed": "Model list failed to load: {message}",
1358
+ "status.providerFailed": "{name} failed to load: {message}",
1359
+ "action.reload": "Reload",
553
1360
  "error.action": "Model operation failed: {message}",
554
1361
  "error.sessionInUse": "This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.",
555
- "warning.groupLoad": "{name} failed to load: {message}",
556
- "empty.models": "No models available.",
557
- "empty.efforts": "This model provides no reasoning effort levels."
1362
+ "picker.title": "Switch model",
1363
+ "picker.close": "Close",
1364
+ "picker.search": "Search models or providers…",
1365
+ "picker.searchEmpty": "No matching models.",
1366
+ "picker.all": "All",
1367
+ "picker.favorite": "Favorite {model}",
1368
+ "picker.unfavorite": "Remove {model} from favorites",
1369
+ "picker.hintNavigate": "navigate",
1370
+ "picker.hintSelect": "select",
1371
+ "picker.hintClose": "close",
1372
+ "meta.effort": "Effort",
1373
+ "meta.images": "Images"
558
1374
  };
559
1375
  //#endregion
560
1376
  //#region src/client/index.ts
561
1377
  /**
562
1378
  * Cordis services required by the browser half. `slots` is the contribution
563
- * registry, `sessions` tells whether a session may select a model, and
1379
+ * registry, `sessions` tells whether a session may select a model,
564
1380
  * `modelDirectories` owns the per-session catalog snapshot this seat renders
565
- * from. `remote` / `remote.session` are declared because a service method runs
566
- * on the calling context: `directoryFor` reads `this.ctx.remote.session` while
567
- * building the directory, so the seat must carry that service too.
1381
+ * from, and `configForms` carries the settings provider's shared describe
1382
+ * mirror, which answers each model's context window and accepted modalities.
1383
+ * `remote`, `remote.llm`, and `remote.session` are declared because a service
1384
+ * method runs on the calling context: `directoryFor` reads
1385
+ * `this.ctx.remote.session`, and the provider directory that names each
1386
+ * settings address is read through `this.ctx.remote.llm`.
568
1387
  */
569
1388
  const inject = [
1389
+ "configForms",
570
1390
  "locale",
571
1391
  "modelDirectories",
572
1392
  "remote",
1393
+ "remote.llm",
573
1394
  "remote.session",
574
1395
  "sessions",
575
1396
  "slots"
@@ -585,9 +1406,19 @@ window.__ModuleLoader__.load({
585
1406
  zh,
586
1407
  en
587
1408
  }), "dsh-model-submenu: dictionaries");
588
- ctx.inject(["slots", "modelDirectories"], (scope) => {
1409
+ ctx.inject([
1410
+ "slots",
1411
+ "modelDirectories",
1412
+ "configForms",
1413
+ "remote"
1414
+ ], (scope) => {
589
1415
  const models = scope.modelDirectories;
590
1416
  const sessions = scope.sessions;
1417
+ const facts = createFactsSource({
1418
+ describe: scope.configForms.describe(),
1419
+ listProviders: () => scope.remote.llm.listConfigurableProviders()
1420
+ });
1421
+ ctx.effect(() => facts.start(), "dsh-model-submenu: model metadata");
591
1422
  scope.slots.inject("conversation.input.model", () => scope.slots.register({
592
1423
  name: "conversation.input.model",
593
1424
  priority: -100,
@@ -600,6 +1431,7 @@ window.__ModuleLoader__.load({
600
1431
  return {
601
1432
  available,
602
1433
  directory: directory.store,
1434
+ factsSource: facts.source,
603
1435
  load: () => {
604
1436
  if (available) directory.load().catch(() => {});
605
1437
  },