@nimoxie/dsh-model-organizer 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/DEVELOPING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +1836 -0
- package/lib/index.js +9 -0
- package/package.json +60 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-model-organizer",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const module = { exports: {} };
|
|
5
|
+
const exports = module.exports;
|
|
6
|
+
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const ReactDOM = require("react-dom");
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
const useState = React.useState;
|
|
11
|
+
const useEffect = React.useEffect;
|
|
12
|
+
const useMemo = React.useMemo;
|
|
13
|
+
const useRef = React.useRef;
|
|
14
|
+
const useLayoutEffect = React.useLayoutEffect;
|
|
15
|
+
const useSyncExternalStore = React.useSyncExternalStore;
|
|
16
|
+
|
|
17
|
+
let P = null;
|
|
18
|
+
try { P = require("@deepseek-ai/dsh-client-ui-primitives"); } catch (e) { P = null; }
|
|
19
|
+
/* React.forwardRef components are OBJECTS (with a .render function), not
|
|
20
|
+
functions — a typeof gate on them silently fails and every consumer
|
|
21
|
+
falls back. Accept both shapes. */
|
|
22
|
+
function isComponent(x) {
|
|
23
|
+
return typeof x === "function" || (x !== null && typeof x === "object" && typeof x.render === "function");
|
|
24
|
+
}
|
|
25
|
+
/* The shipped menus draw their surface (translucent fill + backdrop blur +
|
|
26
|
+
radius) through this primitive; the `_menu` class only adds geometry.
|
|
27
|
+
Rendering our menu through it keeps the frost identical to the shipped
|
|
28
|
+
look — inlining the menu token alone paints a translucent fill with NO
|
|
29
|
+
blur, which reads as "see-through" instead of "frosted". */
|
|
30
|
+
const MENU_SURFACE = P !== null && isComponent(P.MenuSurface) ? P.MenuSurface : null;
|
|
31
|
+
/* Shared model-search ranking — the same primitive the shipped menu uses. */
|
|
32
|
+
const RANK_BY_NAME = P !== null && typeof P.rankByName === "function" ? P.rankByName : null;
|
|
33
|
+
|
|
34
|
+
const NS = "model-organizer";
|
|
35
|
+
const PI_AI_NS = "llm-pi-ai";
|
|
36
|
+
|
|
37
|
+
/* ------------------------------ dictionaries ------------------------------ */
|
|
38
|
+
const zh = {
|
|
39
|
+
"composer.title": "选择模型",
|
|
40
|
+
"composer.menuModel": "模型",
|
|
41
|
+
"composer.searchPlaceholder": "搜索模型…",
|
|
42
|
+
"composer.searchClear": "清除搜索",
|
|
43
|
+
"composer.searchEmpty": "没有匹配的模型。",
|
|
44
|
+
"composer.selectFailed": "选择失败:{msg}",
|
|
45
|
+
"composer.none": "未选择模型",
|
|
46
|
+
"composer.empty": "暂无可用模型",
|
|
47
|
+
"composer.locked": "当前不可切换模型",
|
|
48
|
+
"composer.count": "个模型",
|
|
49
|
+
"composer.aria": "选择模型,当前 {model}",
|
|
50
|
+
"composer.ariaEffort": "选择模型,当前 {model},推理等级 {effort}",
|
|
51
|
+
"composer.effort": "推理等级",
|
|
52
|
+
"composer.effortDefault": "Default",
|
|
53
|
+
"composer.effortEmpty": "当前模型未提供推理等级。",
|
|
54
|
+
"composer.effortBack": "模型",
|
|
55
|
+
"composer.refreshing": "正在刷新模型列表…",
|
|
56
|
+
"order.title": "模型顺序",
|
|
57
|
+
"order.hint": "拖动 ⠿ 调整,松手即保存",
|
|
58
|
+
"order.saving": "保存中…",
|
|
59
|
+
"order.saved": "已保存 ✓",
|
|
60
|
+
"order.unsaved": "有未保存改动",
|
|
61
|
+
"order.readonly": "当前环境不可写入该设置",
|
|
62
|
+
"providers.title": "供应商与模型顺序",
|
|
63
|
+
"providers.hint": "点击收起/展开,长按拖拽调整",
|
|
64
|
+
"providers.expand": "展开顺序面板",
|
|
65
|
+
"providers.collapse": "收起顺序面板",
|
|
66
|
+
"providers.saved": "已保存 ✓",
|
|
67
|
+
};
|
|
68
|
+
const en = {
|
|
69
|
+
"composer.title": "Select model",
|
|
70
|
+
"composer.menuModel": "Model",
|
|
71
|
+
"composer.searchPlaceholder": "Search models…",
|
|
72
|
+
"composer.searchClear": "Clear search",
|
|
73
|
+
"composer.searchEmpty": "No matching models.",
|
|
74
|
+
"composer.selectFailed": "Selection failed: {msg}",
|
|
75
|
+
"composer.none": "No model",
|
|
76
|
+
"composer.empty": "No models available",
|
|
77
|
+
"composer.locked": "Model switching unavailable",
|
|
78
|
+
"composer.count": "models",
|
|
79
|
+
"composer.aria": "Select model, currently {model}",
|
|
80
|
+
"composer.ariaEffort": "Select model, currently {model}, reasoning effort {effort}",
|
|
81
|
+
"composer.effort": "Effort",
|
|
82
|
+
"composer.effortDefault": "Default",
|
|
83
|
+
"composer.effortEmpty": "This model provides no reasoning effort levels.",
|
|
84
|
+
"composer.effortBack": "Model",
|
|
85
|
+
"composer.refreshing": "Refreshing model list…",
|
|
86
|
+
"order.title": "Model order",
|
|
87
|
+
"order.hint": "Drag ⠿ to reorder, then save",
|
|
88
|
+
"order.saving": "Saving…",
|
|
89
|
+
"order.saved": "Saved ✓",
|
|
90
|
+
"order.unsaved": "Unsaved changes",
|
|
91
|
+
"order.readonly": "This settings document is read-only here",
|
|
92
|
+
"providers.title": "Provider and model order",
|
|
93
|
+
"providers.hint": "Click to collapse/expand, press and drag to reorder",
|
|
94
|
+
"providers.expand": "Expand the order panel",
|
|
95
|
+
"providers.collapse": "Collapse the order panel",
|
|
96
|
+
"providers.saved": "Saved ✓",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/* --------------------------- official CSS classes -------------------------- */
|
|
100
|
+
/* The shipped client-ui-model-selection bundle injects its CSS-module sheet and
|
|
101
|
+
STILL loads (this plugin only shadows its seat), so reusing its class names
|
|
102
|
+
reproduces the shipped look exactly. The hash is resolved from the live
|
|
103
|
+
stylesheet so a dsh upgrade cannot silently drop the styling. */
|
|
104
|
+
const CLASS_SUFFIXES = ["root", "trigger", "triggerLabel", "triggerEffort", "triggerIcon", "chevron", "chevronOpen",
|
|
105
|
+
"menu", "status", "empty", "error", "warning", "retry", "groups", "group", "groupTitle",
|
|
106
|
+
"option", "selected", "optionCopy", "modelName", "check", "cell", "cellLabel", "cellValue", "cellChevron",
|
|
107
|
+
"searchRow", "search", "searchWithQuery", "searchClear"];
|
|
108
|
+
|
|
109
|
+
/* Resolution is cached against the live sheet count so a late-injected sheet is
|
|
110
|
+
still picked up, and a resolved prefix is VALIDATED against a second class
|
|
111
|
+
only this module owns — an unrelated module shipping a `_trigger` rule must
|
|
112
|
+
not win (the updater module does). */
|
|
113
|
+
/* Bump on every deploy. The host serves the plugin bundle as
|
|
114
|
+
`immutable, max-age=31536000` under a URL that does not change when a plugin's
|
|
115
|
+
code changes, so a browser can keep running an old build until a hard reload.
|
|
116
|
+
Kept OFF the UI (it is not user-facing): read it as
|
|
117
|
+
`window.__dshModelOrganizerBuild` when a stale build is suspected. */
|
|
118
|
+
const BUILD = "b29";
|
|
119
|
+
|
|
120
|
+
let officialClasses = null;
|
|
121
|
+
let officialClassesSheets = -1;
|
|
122
|
+
let officialClassesValidated = false;
|
|
123
|
+
|
|
124
|
+
function sheetRules() {
|
|
125
|
+
const out = [];
|
|
126
|
+
let sheets;
|
|
127
|
+
try { sheets = document.styleSheets; } catch (e) { return out; }
|
|
128
|
+
for (let i = 0; i < sheets.length; i++) {
|
|
129
|
+
let rules = null;
|
|
130
|
+
try { rules = sheets[i].cssRules; } catch (e) { continue; }
|
|
131
|
+
if (rules === null) continue;
|
|
132
|
+
for (let j = 0; j < rules.length; j++) {
|
|
133
|
+
const sel = rules[j].selectorText;
|
|
134
|
+
if (typeof sel === "string") out.push(sel);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function prefixIsValid(prefix, selectors) {
|
|
141
|
+
const marker = "." + prefix + "_optionCopy";
|
|
142
|
+
for (let i = 0; i < selectors.length; i++) if (selectors[i].indexOf(marker) !== -1) return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveOfficialClasses() {
|
|
147
|
+
let sheetCount = 0;
|
|
148
|
+
try { sheetCount = document.styleSheets.length; } catch (e) { sheetCount = 0; }
|
|
149
|
+
if (officialClassesValidated && officialClassesSheets === sheetCount) return officialClasses;
|
|
150
|
+
/* Anchored on classes only the model selector owns — a bare "_trigger" probe
|
|
151
|
+
also matches unrelated modules (the updater module ships one too). */
|
|
152
|
+
const ANCHORS = ["_triggerEffort", "_optionCopy", "_chevronOpen", "_cellValue"];
|
|
153
|
+
const selectors = sheetRules();
|
|
154
|
+
let prefix = null;
|
|
155
|
+
for (let a = 0; a < ANCHORS.length && prefix === null; a++) {
|
|
156
|
+
for (let i = 0; i < selectors.length && prefix === null; i++) {
|
|
157
|
+
const at = selectors[i].indexOf(ANCHORS[a]);
|
|
158
|
+
if (at === -1) continue;
|
|
159
|
+
const found = /[.]([A-Za-z0-9_-]+)$/.exec(selectors[i].slice(0, at));
|
|
160
|
+
if (found === null) continue;
|
|
161
|
+
if (!prefixIsValid(found[1], selectors)) continue;
|
|
162
|
+
prefix = found[1];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
officialClassesSheets = sheetCount;
|
|
166
|
+
officialClassesValidated = true;
|
|
167
|
+
if (prefix === null) { officialClasses = null; return null; }
|
|
168
|
+
const out = {};
|
|
169
|
+
for (let i = 0; i < CLASS_SUFFIXES.length; i++) out[CLASS_SUFFIXES[i]] = prefix + "_" + CLASS_SUFFIXES[i];
|
|
170
|
+
/* The search row ships only in DSH 0.2.0-rc.2+; flag its presence so the
|
|
171
|
+
composer renders the search box only where the official sheet can style
|
|
172
|
+
it (rc.1's sheet has no search rules and would leave it unstyled). */
|
|
173
|
+
const searchMarker = "." + prefix + "_searchRow";
|
|
174
|
+
for (let i = 0; i < selectors.length; i++) {
|
|
175
|
+
if (selectors[i].indexOf(searchMarker) !== -1) { out.hasSearchRow = true; break; }
|
|
176
|
+
}
|
|
177
|
+
officialClasses = out;
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The shipped sheet styles a static group heading; the collapsible variant needs
|
|
182
|
+
a hover affordance and a chevron that flips. Injected once per document. */
|
|
183
|
+
const OWN_STYLE_ID = "dsh-model-organizer-style";
|
|
184
|
+
function ensureOwnStyles(C) {
|
|
185
|
+
try {
|
|
186
|
+
let style = document.getElementById(OWN_STYLE_ID);
|
|
187
|
+
if (style === null) {
|
|
188
|
+
style = document.createElement("style");
|
|
189
|
+
style.id = OWN_STYLE_ID;
|
|
190
|
+
document.head.appendChild(style);
|
|
191
|
+
}
|
|
192
|
+
const rules = [
|
|
193
|
+
".dsh-mo-head{transition:background .12s ease;}",
|
|
194
|
+
".dsh-mo-head:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
195
|
+
/* A drag leaves the source row FOCUSED in Chromium, and both the shipped
|
|
196
|
+
\`option\` and this sheet paint a :focus-visible background — that is the grey
|
|
197
|
+
block that used to stay behind after a drag. That suppression must stay
|
|
198
|
+
scoped to the settings drag lists (\.dsh-mo-list): the composer menu rows
|
|
199
|
+
take keyboard focus now, and the walk is invisible without the paint. */
|
|
200
|
+
".dsh-mo-head:focus-visible{outline:none;background:var(--dsw-alias-interactive-bg-hover);}",
|
|
201
|
+
".dsh-mo-list .dsh-mo-head.dsh-mo-head:focus-visible,.dsh-mo-list .dsh-mo-model.dsh-mo-model:focus-visible,.dsh-mo-list .dsh-mo-row.dsh-mo-row:focus-visible{background:transparent;}",
|
|
202
|
+
".dsh-mo-panelhead.dsh-mo-panelhead:focus-visible{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
203
|
+
".dsh-mo-chev{transition:transform .12s ease;}",
|
|
204
|
+
".dsh-mo-head[aria-expanded='true'] .dsh-mo-chev{transform:rotate(90deg);}",
|
|
205
|
+
".dsh-mo-row{border-width:1px;border-style:solid;border-color:transparent;}",
|
|
206
|
+
/* No row may paint a hover tint while a drag owns the pointer. */
|
|
207
|
+
".dsh-mo-nohover.dsh-mo-nohover .dsh-mo-head:hover,.dsh-mo-nohover.dsh-mo-nohover .dsh-mo-row:hover,.dsh-mo-nohover.dsh-mo-nohover .dsh-mo-model:hover{background:transparent;}",
|
|
208
|
+
|
|
209
|
+
/* The row IS the handle now: no grip glyph, the cursor says "drag me".
|
|
210
|
+
Scoped to the settings panel's own list — the composer menu reuses the
|
|
211
|
+
shipped cell/option classes together with dsh-mo-head / dsh-mo-model for
|
|
212
|
+
rows that are NOT draggable, and those must keep the shipped cursor. */
|
|
213
|
+
".dsh-mo-list .dsh-mo-head{cursor:grab;}",
|
|
214
|
+
".dsh-mo-list .dsh-mo-model{cursor:grab;}",
|
|
215
|
+
".dsh-mo-list .dsh-mo-row{cursor:grab;}",
|
|
216
|
+
".dsh-mo-list .dsh-mo-head:active,.dsh-mo-list .dsh-mo-model:active{cursor:grabbing;}",
|
|
217
|
+
/* The panel header is a drag handle, not a button: keep the cursor, drop the
|
|
218
|
+
hover tint. Doubled selectors outrank the shipped `cell` :hover rule. */
|
|
219
|
+
".dsh-mo-panelhead.dsh-mo-panelhead{cursor:grab;}",
|
|
220
|
+
".dsh-mo-panelhead.dsh-mo-panelhead:hover{background:transparent;}",
|
|
221
|
+
".dsh-mo-list{display:flex;flex-direction:column;gap:2px;margin-top:2px;}",
|
|
222
|
+
".dsh-mo-head{touch-action:none;user-select:none;-webkit-user-select:none;}",
|
|
223
|
+
/* Borrowed from the sidebar rows: without -webkit-user-drag the browser treats
|
|
224
|
+
a press-and-move over the row's TEXT as a NATIVE TEXT DRAG (the "release to
|
|
225
|
+
search" ghost) instead of our reorder drag. It must NOT go on the row itself
|
|
226
|
+
— that disables the row's own draggable behaviour — so it goes on the row's
|
|
227
|
+
contents, leaving the row draggable. */
|
|
228
|
+
".dsh-mo-list .dsh-mo-head,.dsh-mo-list .dsh-mo-model,.dsh-mo-list .dsh-mo-row{user-select:none;-webkit-user-select:none;}",
|
|
229
|
+
".dsh-mo-list .dsh-mo-head *,.dsh-mo-list .dsh-mo-model *,.dsh-mo-list .dsh-mo-row *{-webkit-user-drag:none;user-select:none;-webkit-user-select:none;}",
|
|
230
|
+
".dsh-mo-panelhead,.dsh-mo-panelhead *{-webkit-user-drag:none;user-select:none;-webkit-user-select:none;}",
|
|
231
|
+
".dsh-mo-moving,.dsh-mo-moving:hover{cursor:grabbing;background:transparent;}",
|
|
232
|
+
/* (the header's own arrow button is gone — the row toggles on click) */
|
|
233
|
+
|
|
234
|
+
".dsh-mo-model{padding-left:28px;position:relative;}",
|
|
235
|
+
".dsh-mo-model::before{content:'';position:absolute;left:15px;top:50%;width:5px;height:5px;margin-top:-2.5px;border-radius:50%;background:var(--dsw-alias-label-tertiary);opacity:.5;}",
|
|
236
|
+
".dsh-mo-model[aria-checked='true']::before{background:var(--dsw-alias-label-primary);opacity:.9;}"
|
|
237
|
+
];
|
|
238
|
+
/* The shipped check alone is easy to miss: bold the selected row's name
|
|
239
|
+
wherever the official sheet is in use. Settings rows carry no
|
|
240
|
+
aria-checked, so the drag lists are untouched. */
|
|
241
|
+
if (C !== null) {
|
|
242
|
+
rules.push("." + C.option + "[aria-checked='true'] ." + C.modelName + "{font-weight:600}");
|
|
243
|
+
}
|
|
244
|
+
style.textContent = rules.join("");
|
|
245
|
+
} catch (e) {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function cx() {
|
|
249
|
+
let out = "";
|
|
250
|
+
for (let i = 0; i < arguments.length; i++) {
|
|
251
|
+
const v = arguments[i];
|
|
252
|
+
if (typeof v === "string" && v.length > 0) out = out.length === 0 ? v : out + " " + v;
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function icon(name, props) {
|
|
258
|
+
if (P === null || !isComponent(P[name])) return null;
|
|
259
|
+
return h(P[name], props ? Object.assign({}, props) : {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* --------------------------------- styles --------------------------------- */
|
|
263
|
+
const S = {
|
|
264
|
+
chip: {
|
|
265
|
+
display: "inline-flex", alignItems: "center", gap: 6, maxWidth: 260,
|
|
266
|
+
padding: "3px 8px", borderRadius: 8, cursor: "pointer", fontSize: 12, lineHeight: "18px",
|
|
267
|
+
border: "1px solid var(--dsw-alias-border-l1)",
|
|
268
|
+
background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
|
|
269
|
+
color: "var(--dsw-alias-label-primary)"
|
|
270
|
+
},
|
|
271
|
+
chipDisabled: { opacity: 0.5, cursor: "not-allowed" },
|
|
272
|
+
chipLabel: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
273
|
+
chipEffort: { color: "var(--dsw-alias-label-caption)", flexShrink: 1000, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
|
|
274
|
+
caret: { fontSize: 9, color: "var(--dsw-alias-label-tertiary)" },
|
|
275
|
+
menuEmpty: { padding: "10px 8px", fontSize: 12, color: "var(--dsw-alias-label-tertiary)" },
|
|
276
|
+
/* fallbacks used only when the shipped stylesheet could not be located */
|
|
277
|
+
fallbackMenu: {
|
|
278
|
+
position: "fixed", zIndex: 1100, maxHeight: "min(360px, 100vh - 96px)", overflow: "hidden",
|
|
279
|
+
display: "flex", flexDirection: "column", padding: 4,
|
|
280
|
+
background: "var(--dsw-specific-menu, var(--dsw-alias-bg-module, #222))",
|
|
281
|
+
color: "var(--dsw-alias-label-primary)", borderRadius: 20, boxShadow: "0 8px 28px rgba(0,0,0,.32)"
|
|
282
|
+
},
|
|
283
|
+
fallbackGroups: { overflowY: "auto", minHeight: 0, display: "flex", flexDirection: "column", gap: 4 },
|
|
284
|
+
fallbackGroup: { display: "flex", flexDirection: "column" },
|
|
285
|
+
fallbackGroupTitle: {
|
|
286
|
+
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6, width: "100%",
|
|
287
|
+
textAlign: "left", padding: "5px 8px 3px", border: "none", cursor: "pointer",
|
|
288
|
+
background: "transparent", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, fontWeight: 500
|
|
289
|
+
},
|
|
290
|
+
fallbackModel: {
|
|
291
|
+
display: "flex", alignItems: "center", gap: 8, width: "100%", minHeight: 38,
|
|
292
|
+
textAlign: "left", padding: "6px 8px", border: "none", borderRadius: 10, cursor: "pointer",
|
|
293
|
+
background: "transparent", color: "inherit"
|
|
294
|
+
},
|
|
295
|
+
fallbackCopy: { display: "flex", flexDirection: "column", flex: 1, minWidth: 0 },
|
|
296
|
+
fallbackCheck: { flex: "0 0 18px", display: "grid", placeItems: "center" },
|
|
297
|
+
/* the shipped groupTitle is a plain heading; the clickable variant only adds
|
|
298
|
+
layout + affordance so the visuals keep coming from the shipped sheet. */
|
|
299
|
+
groupChevron: { color: "var(--dsw-alias-label-tertiary)", flex: "none", transition: "transform .12s" },
|
|
300
|
+
measureStyle: { visibility: "hidden", left: 0, top: 0 },
|
|
301
|
+
groupCount: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)", flex: "none" },
|
|
302
|
+
failure: { display: "flex", flexDirection: "column", gap: 2, padding: "5px 8px", fontSize: 11, color: "var(--dsw-alias-state-warn-label, #d29922)" },
|
|
303
|
+
box: { marginTop: 8, padding: 8, border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 10, background: "var(--dsw-alias-bg-module-secondary, transparent)" },
|
|
304
|
+
providerGroup: { display: "flex", flexDirection: "column" },
|
|
305
|
+
|
|
306
|
+
/* Floating presentation: the panel sits in the corner of the Models page so
|
|
307
|
+
provider/API configuration and reordering are visible at the same time. */
|
|
308
|
+
floatPanel: {
|
|
309
|
+
position: "fixed", right: 20, bottom: 20, zIndex: 10000, width: 380, maxWidth: "calc(100vw - 40px)",
|
|
310
|
+
display: "flex", flexDirection: "column", padding: 4,
|
|
311
|
+
border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 14,
|
|
312
|
+
/* The theme's menu token is an OPAQUE colour (a color-mix), so using it as-is
|
|
313
|
+
makes this panel a solid box and the blur below has nothing to show. Mixing
|
|
314
|
+
it back toward transparent restores the frosted look in the theme's colour. */
|
|
315
|
+
background: "color-mix(in srgb, var(--dsw-specific-menu, #26262a) 50%, transparent)",
|
|
316
|
+
backdropFilter: "var(--dsw-menu-backdrop-filter, blur(28px) saturate(1.6))",
|
|
317
|
+
boxShadow: "var(--dsw-elevation-prominent, 0 10px 30px rgba(0,0,0,.34))",
|
|
318
|
+
color: "var(--dsw-alias-label-primary)"
|
|
319
|
+
},
|
|
320
|
+
/* The shipped `cell` sets min-width:100%; the head must stay inside the panel. */
|
|
321
|
+
panelHeadCell: { display: "flex", alignItems: "center", gap: 6, flex: "1 1 auto", minWidth: 0 },
|
|
322
|
+
floatBody: { display: "flex", flexDirection: "column", gap: 2, overflowY: "auto", maxHeight: "52vh", padding: "2px 4px 4px" },
|
|
323
|
+
|
|
324
|
+
boxHead: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6, flexWrap: "wrap" },
|
|
325
|
+
boxTitle: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
|
|
326
|
+
boxHint: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
|
|
327
|
+
list: { display: "flex", flexDirection: "column", gap: 2 },
|
|
328
|
+
row: {
|
|
329
|
+
display: "flex", alignItems: "center", gap: 8, padding: "5px 6px", borderRadius: 7,
|
|
330
|
+
fontSize: 12, color: "var(--dsw-alias-label-secondary)", outline: "none",
|
|
331
|
+
borderWidth: 1, borderStyle: "solid", borderColor: "transparent",
|
|
332
|
+
background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.06))",
|
|
333
|
+
transition: "background .12s ease, border-color .12s ease, opacity .12s ease, transform .12s ease",
|
|
334
|
+
cursor: "grab"
|
|
335
|
+
},
|
|
336
|
+
/* Only border-color changes while dragging: keeping the border as longhands
|
|
337
|
+
everywhere stops React from expanding the shorthand and then dropping
|
|
338
|
+
border-color, which would fall back to currentColor and look like a
|
|
339
|
+
highlight that never clears. */
|
|
340
|
+
/* Only the drag affordances ride inline when the shipped classes are in use. */
|
|
341
|
+
rowDraggingOnly: {
|
|
342
|
+
opacity: 0.55, cursor: "grabbing", borderColor: "var(--dsw-alias-state-accent, #4f8cff)", transform: "scale(1.01)"
|
|
343
|
+
},
|
|
344
|
+
rowDragging: {
|
|
345
|
+
opacity: 0.55, cursor: "grabbing",
|
|
346
|
+
borderColor: "var(--dsw-alias-state-accent, #4f8cff)",
|
|
347
|
+
background: "var(--dsw-alias-interactive-bg, rgba(79,140,255,.14))",
|
|
348
|
+
transform: "scale(1.01)"
|
|
349
|
+
},
|
|
350
|
+
rowName: { flex: "1 1 auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
351
|
+
/* Right-aligned "N models" count on a provider row (fallback styling only). */
|
|
352
|
+
rowMeta: { flex: "none", fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
|
|
353
|
+
dirty: { fontSize: 11, color: "var(--dsw-alias-state-warn-label, #d29922)" },
|
|
354
|
+
ok: { fontSize: 11, color: "var(--dsw-alias-state-success, #3fb950)" }
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/* --------------------------- drag reorder helper -------------------------- */
|
|
358
|
+
/* Live preview: the list re-renders in the would-be order while dragging, so
|
|
359
|
+
neighbouring rows visibly move out of the way before the drop lands. */
|
|
360
|
+
const MEASURE_STYLE = S.measureStyle;
|
|
361
|
+
|
|
362
|
+
function moved(ids, from, to) {
|
|
363
|
+
const next = ids.slice();
|
|
364
|
+
const item = next.splice(from, 1)[0];
|
|
365
|
+
next.splice(to, 0, item);
|
|
366
|
+
return next;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* A transient status note. The timer is kept per instance so a second save cannot
|
|
370
|
+
be cut short by the first save's timer, and nothing fires after unmount. */
|
|
371
|
+
function useNote() {
|
|
372
|
+
const state = useState(null);
|
|
373
|
+
const timer = useRef(null);
|
|
374
|
+
useEffect(function () {
|
|
375
|
+
return function () {
|
|
376
|
+
if (timer.current !== null) clearTimeout(timer.current);
|
|
377
|
+
};
|
|
378
|
+
}, []);
|
|
379
|
+
const set = function (value, ttl) {
|
|
380
|
+
if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; }
|
|
381
|
+
state[1](value);
|
|
382
|
+
if (value !== null && typeof ttl === "number") {
|
|
383
|
+
timer.current = setTimeout(function () {
|
|
384
|
+
timer.current = null;
|
|
385
|
+
state[1](null);
|
|
386
|
+
}, ttl);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
return [state[0], set];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function DragList(props) {
|
|
393
|
+
const ids = props.ids;
|
|
394
|
+
const render = props.render;
|
|
395
|
+
const onCommit = props.onCommit;
|
|
396
|
+
const disabled = props.disabled;
|
|
397
|
+
const dragState = useState(null);
|
|
398
|
+
const drag = dragState[0];
|
|
399
|
+
const setDrag = dragState[1];
|
|
400
|
+
/* The live value lives in a ref as well as in state: dragover fires many times
|
|
401
|
+
per frame, and a handler reading the rendered state would compute from a
|
|
402
|
+
stale list — that is what let the highlighted row drift away from the row
|
|
403
|
+
actually being dragged. */
|
|
404
|
+
const dragRef = useRef(null);
|
|
405
|
+
const handled = useRef(false);
|
|
406
|
+
/* While an HTML5 drag session runs, Chromium owns the pointer and stops
|
|
407
|
+
updating :hover — so the row that was under the cursor when the press
|
|
408
|
+
started keeps its hover tint, frozen at the position it was pressed at,
|
|
409
|
+
while the dragged row travels with the pointer. Hover paint is therefore
|
|
410
|
+
suppressed for the whole gesture (and until the pointer moves again, so a
|
|
411
|
+
frozen tint can never survive the drop). The dragged row keeps its own
|
|
412
|
+
accent border + opacity, so the feedback is not lost. */
|
|
413
|
+
const lockState = useState(false);
|
|
414
|
+
const hoverLocked = lockState[0];
|
|
415
|
+
const setHoverLocked = lockState[1];
|
|
416
|
+
|
|
417
|
+
useEffect(function () {
|
|
418
|
+
if (!hoverLocked) return undefined;
|
|
419
|
+
const release = function () { if (dragRef.current === null) setHoverLocked(false); };
|
|
420
|
+
window.addEventListener("pointermove", release, true);
|
|
421
|
+
window.addEventListener("mousemove", release, true);
|
|
422
|
+
window.addEventListener("pointerdown", release, true);
|
|
423
|
+
const timer = setTimeout(release, 400);
|
|
424
|
+
return function () {
|
|
425
|
+
window.removeEventListener("pointermove", release, true);
|
|
426
|
+
window.removeEventListener("mousemove", release, true);
|
|
427
|
+
window.removeEventListener("pointerdown", release, true);
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
};
|
|
430
|
+
}, [hoverLocked]);
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
const shown = drag !== null ? drag.preview : ids;
|
|
434
|
+
|
|
435
|
+
function put(next) {
|
|
436
|
+
dragRef.current = next;
|
|
437
|
+
setDrag(next);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* Identity, not position: the dragged row is remembered by its id, so the
|
|
441
|
+
highlight follows the row itself wherever the preview moves it. */
|
|
442
|
+
function start(id, event) {
|
|
443
|
+
if (disabled) return;
|
|
444
|
+
handled.current = false;
|
|
445
|
+
setHoverLocked(true);
|
|
446
|
+
/* Chromium focuses the mousedown target; a native drag then keeps it focused
|
|
447
|
+
for the rest of the gesture, which paints a focus background on the row. */
|
|
448
|
+
try { if (event && event.currentTarget && event.currentTarget.blur) event.currentTarget.blur(); } catch (e) {}
|
|
449
|
+
if (event.dataTransfer) {
|
|
450
|
+
event.dataTransfer.effectAllowed = "move";
|
|
451
|
+
try { event.dataTransfer.setData("text/plain", String(id)); } catch (e) {}
|
|
452
|
+
}
|
|
453
|
+
put({ id: id, preview: ids });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function over(rowId, event) {
|
|
457
|
+
const current = dragRef.current;
|
|
458
|
+
if (disabled || current === null) return;
|
|
459
|
+
event.preventDefault();
|
|
460
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
461
|
+
const list = current.preview;
|
|
462
|
+
const from = list.indexOf(current.id);
|
|
463
|
+
const index = list.indexOf(rowId);
|
|
464
|
+
if (from < 0 || index < 0) return;
|
|
465
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
466
|
+
const below = event.clientY > rect.top + rect.height / 2;
|
|
467
|
+
let to = below ? index + 1 : index;
|
|
468
|
+
if (from < to) to -= 1;
|
|
469
|
+
to = Math.max(0, Math.min(list.length - 1, to));
|
|
470
|
+
if (to === from) return;
|
|
471
|
+
put({ id: current.id, preview: moved(list, from, to) });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* Safety net: if a drag ends without reaching our drop handler (dropped on the
|
|
475
|
+
page background, cancelled, window blurred, source node re-rendered), the
|
|
476
|
+
highlight must not stay behind. Bubble phase, so a real drop still commits
|
|
477
|
+
first. */
|
|
478
|
+
useEffect(function () {
|
|
479
|
+
if (drag === null) return undefined;
|
|
480
|
+
const clear = function () {
|
|
481
|
+
dragRef.current = null;
|
|
482
|
+
setDrag(null);
|
|
483
|
+
handled.current = false;
|
|
484
|
+
};
|
|
485
|
+
window.addEventListener("dragend", clear);
|
|
486
|
+
window.addEventListener("drop", clear);
|
|
487
|
+
window.addEventListener("mouseup", clear);
|
|
488
|
+
window.addEventListener("blur", clear);
|
|
489
|
+
return function () {
|
|
490
|
+
window.removeEventListener("dragend", clear);
|
|
491
|
+
window.removeEventListener("drop", clear);
|
|
492
|
+
window.removeEventListener("mouseup", clear);
|
|
493
|
+
window.removeEventListener("blur", clear);
|
|
494
|
+
};
|
|
495
|
+
}, [drag === null]);
|
|
496
|
+
|
|
497
|
+
function clearFocus(node) {
|
|
498
|
+
try {
|
|
499
|
+
if (node && typeof node.blur === "function") node.blur();
|
|
500
|
+
const active = document.activeElement;
|
|
501
|
+
if (active && typeof active.blur === "function" && active !== document.body) active.blur();
|
|
502
|
+
} catch (e) {}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function drop(event) {
|
|
506
|
+
const current = dragRef.current;
|
|
507
|
+
if (current === null || handled.current) return;
|
|
508
|
+
handled.current = true;
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
put(null);
|
|
511
|
+
clearFocus(event.currentTarget);
|
|
512
|
+
onCommit(current.preview);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function end(event) {
|
|
516
|
+
put(null);
|
|
517
|
+
handled.current = false;
|
|
518
|
+
clearFocus(event ? event.currentTarget : null);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return h("div", {
|
|
522
|
+
className: hoverLocked ? "dsh-mo-list dsh-mo-nohover" : "dsh-mo-list",
|
|
523
|
+
style: S.list,
|
|
524
|
+
onDragEnd: end,
|
|
525
|
+
onDrop: drop
|
|
526
|
+
},
|
|
527
|
+
shown.map(function (id, index) {
|
|
528
|
+
const dragging = drag !== null && drag.id === id;
|
|
529
|
+
return render(id, index, {
|
|
530
|
+
draggable: !disabled,
|
|
531
|
+
dragging: dragging,
|
|
532
|
+
onDragStart: function (event) { start(id, event); },
|
|
533
|
+
onDragOver: function (event) { over(id, event); },
|
|
534
|
+
onDragEnd: end,
|
|
535
|
+
onDrop: drop
|
|
536
|
+
});
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* ---------------- A. composer model selector, grouped by provider ---------- */
|
|
542
|
+
function ProviderGroupedModelSelect(props) {
|
|
543
|
+
const locked = props.locked;
|
|
544
|
+
const available = props.available;
|
|
545
|
+
const directory = props.directory;
|
|
546
|
+
const load = props.load;
|
|
547
|
+
const select = props.select;
|
|
548
|
+
const t = props.t;
|
|
549
|
+
|
|
550
|
+
const C = resolveOfficialClasses();
|
|
551
|
+
ensureOwnStyles(C);
|
|
552
|
+
const state = useSyncExternalStore(
|
|
553
|
+
function (fn) { return directory.subscribe(fn); },
|
|
554
|
+
function () { return directory.getSnapshot(); }
|
|
555
|
+
);
|
|
556
|
+
const openState = useState(false);
|
|
557
|
+
const open = openState[0];
|
|
558
|
+
const setOpen = openState[1];
|
|
559
|
+
const expandedState = useState({});
|
|
560
|
+
const expanded = expandedState[0];
|
|
561
|
+
const setExpanded = expandedState[1];
|
|
562
|
+
const queryState = useState("");
|
|
563
|
+
const query = queryState[0];
|
|
564
|
+
const setQuery = queryState[1];
|
|
565
|
+
const searchRef = useRef(null);
|
|
566
|
+
const toastState = useState(null);
|
|
567
|
+
const toast = toastState[0];
|
|
568
|
+
const setToast = toastState[1];
|
|
569
|
+
const toastSeq = useRef(0);
|
|
570
|
+
const posState = useState(null);
|
|
571
|
+
const menuPos = posState[0];
|
|
572
|
+
const setMenuPos = posState[1];
|
|
573
|
+
const paneState = useState("root");
|
|
574
|
+
const pane = paneState[0];
|
|
575
|
+
const setPane = paneState[1];
|
|
576
|
+
const rootRef = useRef(null);
|
|
577
|
+
const triggerRef = useRef(null);
|
|
578
|
+
const menuRef = useRef(null);
|
|
579
|
+
const idRef = useRef(null);
|
|
580
|
+
if (idRef.current === null) idRef.current = "mo-" + Math.random().toString(36).slice(2, 9);
|
|
581
|
+
/* Row registry for the shipped keyboard walk (↑/↓ cycles the shown pane's
|
|
582
|
+
rows). Refs are handed out during render, exactly like the shipped menu. */
|
|
583
|
+
const itemRefs = useRef([]);
|
|
584
|
+
const itemIndex = useRef(0);
|
|
585
|
+
const itemRef = function () {
|
|
586
|
+
const at = itemIndex.current++;
|
|
587
|
+
return function (node) { itemRefs.current[at] = node; };
|
|
588
|
+
};
|
|
589
|
+
itemIndex.current = 0;
|
|
590
|
+
itemRefs.current = [];
|
|
591
|
+
|
|
592
|
+
const groups = (state && state.groups) || [];
|
|
593
|
+
const failures = (state && state.failures) || [];
|
|
594
|
+
const current = (state && state.current) || null;
|
|
595
|
+
const busy = !!state && state.status === "selecting";
|
|
596
|
+
const loading = !!state && state.status === "loading";
|
|
597
|
+
|
|
598
|
+
/* Providers follow the plugin's own display preference (see below). Computed
|
|
599
|
+
per render — not memoised — so a reorder made in Settings shows up here the
|
|
600
|
+
next time the menu opens, without a page reload. */
|
|
601
|
+
const groupsOrdered = (function () {
|
|
602
|
+
const ids = [];
|
|
603
|
+
const byId = {};
|
|
604
|
+
for (let i = 0; i < groups.length; i++) { ids.push(groups[i].id); byId[groups[i].id] = groups[i]; }
|
|
605
|
+
const ordered = applyProviderOrder(ids, readProviderOrder());
|
|
606
|
+
const out = [];
|
|
607
|
+
for (let i = 0; i < ordered.length; i++) if (byId[ordered[i]] !== undefined) out.push(byId[ordered[i]]);
|
|
608
|
+
return out;
|
|
609
|
+
})();
|
|
610
|
+
|
|
611
|
+
/* Search, mirroring the shipped menu: offered only above four models and
|
|
612
|
+
only where the official sheet ships the search styling (0.2.0-rc.2+).
|
|
613
|
+
Matches rank through the shared rankByName, per group, empty groups
|
|
614
|
+
dropping out; a query forces every group open. */
|
|
615
|
+
const modelCount = (function () {
|
|
616
|
+
let n = 0;
|
|
617
|
+
for (let i = 0; i < groupsOrdered.length; i++) n += groupsOrdered[i].models.length;
|
|
618
|
+
return n;
|
|
619
|
+
})();
|
|
620
|
+
const showSearch = C !== null && C.hasSearchRow === true && RANK_BY_NAME !== null
|
|
621
|
+
&& P !== null && isComponent(P.Input) && modelCount > 4;
|
|
622
|
+
const searchQuery = showSearch ? query.trim() : "";
|
|
623
|
+
const filteredGroups = (function () {
|
|
624
|
+
if (searchQuery === "") return groupsOrdered;
|
|
625
|
+
const out = [];
|
|
626
|
+
for (let i = 0; i < groupsOrdered.length; i++) {
|
|
627
|
+
const group = groupsOrdered[i];
|
|
628
|
+
const models = RANK_BY_NAME(group.models, searchQuery);
|
|
629
|
+
if (models.length === 0) continue;
|
|
630
|
+
const copy = {};
|
|
631
|
+
for (const key in group) copy[key] = group[key];
|
|
632
|
+
copy.models = models;
|
|
633
|
+
out.push(copy);
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
})();
|
|
637
|
+
|
|
638
|
+
const layoutKey = useMemo(function () {
|
|
639
|
+
const parts = [];
|
|
640
|
+
for (let i = 0; i < groupsOrdered.length; i++) parts.push(groupsOrdered[i].id + (expanded[groupsOrdered[i].id] === true ? "+" : "-"));
|
|
641
|
+
return parts.join("|") + "|" + String(current === null ? "" : current.provider);
|
|
642
|
+
}, [groupsOrdered, expanded, current]);
|
|
643
|
+
|
|
644
|
+
useEffect(function () { load(); }, []);
|
|
645
|
+
|
|
646
|
+
useEffect(function () {
|
|
647
|
+
if (!open) return undefined;
|
|
648
|
+
const onDown = function (event) {
|
|
649
|
+
if (triggerRef.current && triggerRef.current.contains(event.target)) return;
|
|
650
|
+
if (menuRef.current && menuRef.current.contains(event.target)) return;
|
|
651
|
+
setOpen(false);
|
|
652
|
+
};
|
|
653
|
+
const onKey = function (event) {
|
|
654
|
+
if (event.key !== "Escape") return;
|
|
655
|
+
setPane(function (currentPane) {
|
|
656
|
+
if (currentPane !== "root") return "root";
|
|
657
|
+
setOpen(false);
|
|
658
|
+
return currentPane;
|
|
659
|
+
});
|
|
660
|
+
};
|
|
661
|
+
document.addEventListener("mousedown", onDown);
|
|
662
|
+
document.addEventListener("keydown", onKey);
|
|
663
|
+
return function () {
|
|
664
|
+
document.removeEventListener("mousedown", onDown);
|
|
665
|
+
document.removeEventListener("keydown", onKey);
|
|
666
|
+
};
|
|
667
|
+
}, [open]);
|
|
668
|
+
|
|
669
|
+
/* Focus hand-off, mirroring the shipped menu: drilling hands focus to the
|
|
670
|
+
pane's first enabled row, returning to the root pane hands it back to
|
|
671
|
+
its first cell. A plain open/close does NOT move focus (it stays on the
|
|
672
|
+
trigger, like the shipped menu). */
|
|
673
|
+
const wasOpen = useRef(false);
|
|
674
|
+
useEffect(function () {
|
|
675
|
+
const navigating = wasOpen.current && open;
|
|
676
|
+
wasOpen.current = open;
|
|
677
|
+
if (!open || !navigating) return undefined;
|
|
678
|
+
if (pane === "model" && searchRef.current !== null && searchRef.current.focus) {
|
|
679
|
+
try { searchRef.current.focus(); } catch (e) {}
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
const items = itemRefs.current.filter(function (node) {
|
|
683
|
+
return node !== null && node !== undefined && node.disabled !== true;
|
|
684
|
+
});
|
|
685
|
+
if (items.length > 0) {
|
|
686
|
+
try { items[0].focus(); } catch (e) {}
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
}, [open, pane]);
|
|
690
|
+
|
|
691
|
+
const currentInfo = useMemo(function () {
|
|
692
|
+
if (current === null) return null;
|
|
693
|
+
for (let i = 0; i < groups.length; i++) {
|
|
694
|
+
const group = groups[i];
|
|
695
|
+
if (group.id !== current.provider) continue;
|
|
696
|
+
for (let j = 0; j < group.models.length; j++) {
|
|
697
|
+
if (group.models[j].id === current.model) return { group: group, model: group.models[j] };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}, [groups, current]);
|
|
702
|
+
|
|
703
|
+
/* Reasoning effort, mirroring the shipped ModelSelect: exact-model metadata
|
|
704
|
+
comes from the Host, and the effective level is the session's explicit
|
|
705
|
+
choice or the model's own default. */
|
|
706
|
+
const reasoning = currentInfo !== null && currentInfo.model.reasoning ? currentInfo.model.reasoning : undefined;
|
|
707
|
+
const effectiveEffort = current !== null && current.reasoningEffort !== undefined
|
|
708
|
+
? current.reasoningEffort
|
|
709
|
+
: (reasoning !== undefined ? reasoning.defaultEffort : undefined);
|
|
710
|
+
let effortLabel;
|
|
711
|
+
if (reasoning !== undefined) {
|
|
712
|
+
if (effectiveEffort === undefined) effortLabel = t("composer.effortDefault");
|
|
713
|
+
else {
|
|
714
|
+
const levels = reasoning.efforts || [];
|
|
715
|
+
let found;
|
|
716
|
+
for (let i = 0; i < levels.length; i++) if (levels[i].id === effectiveEffort) found = levels[i];
|
|
717
|
+
effortLabel = found !== undefined ? found.name : effectiveEffort;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const effortChoices = useMemo(function () {
|
|
721
|
+
if (reasoning === undefined) return [];
|
|
722
|
+
const out = [];
|
|
723
|
+
if (reasoning.defaultEffort === undefined) out.push({ key: "provider-default", effort: undefined, label: t("composer.effortDefault") });
|
|
724
|
+
const levels = reasoning.efforts || [];
|
|
725
|
+
for (let i = 0; i < levels.length; i++) out.push({ key: "effort:" + levels[i].id, effort: levels[i].id, label: levels[i].name });
|
|
726
|
+
return out;
|
|
727
|
+
}, [reasoning, t]);
|
|
728
|
+
|
|
729
|
+
/* Official placement: right-aligned above the trigger, clamped to the
|
|
730
|
+
viewport, re-measured on scroll/resize (mirrors the shipped algorithm). */
|
|
731
|
+
useLayoutEffect(function () {
|
|
732
|
+
if (!open) { setMenuPos(null); return undefined; }
|
|
733
|
+
const place = function () {
|
|
734
|
+
const rect = triggerRef.current ? triggerRef.current.getBoundingClientRect() : undefined;
|
|
735
|
+
if (rect === undefined) return;
|
|
736
|
+
const MARGIN = 12;
|
|
737
|
+
const lw = menuRef.current ? menuRef.current.offsetWidth : 0;
|
|
738
|
+
const lh = menuRef.current ? menuRef.current.offsetHeight : 0;
|
|
739
|
+
let x = rect.right - lw;
|
|
740
|
+
let y = rect.top - 8 - lh;
|
|
741
|
+
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN);
|
|
742
|
+
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN);
|
|
743
|
+
setMenuPos({ left: x, top: y });
|
|
744
|
+
};
|
|
745
|
+
place();
|
|
746
|
+
window.addEventListener("scroll", place, true);
|
|
747
|
+
window.addEventListener("resize", place);
|
|
748
|
+
return function () {
|
|
749
|
+
window.removeEventListener("scroll", place, true);
|
|
750
|
+
window.removeEventListener("resize", place);
|
|
751
|
+
};
|
|
752
|
+
}, [open, pane, searchQuery, layoutKey]);
|
|
753
|
+
|
|
754
|
+
if (available === false) return null;
|
|
755
|
+
|
|
756
|
+
const disabled = !!locked;
|
|
757
|
+
|
|
758
|
+
function toggle() {
|
|
759
|
+
if (disabled) return;
|
|
760
|
+
if (!open) {
|
|
761
|
+
load();
|
|
762
|
+
setQuery("");
|
|
763
|
+
setPane(current === null ? "model" : "root");
|
|
764
|
+
if (Object.keys(expanded).length === 0 && current !== null) {
|
|
765
|
+
const seed = {};
|
|
766
|
+
seed[current.provider] = true;
|
|
767
|
+
setExpanded(seed);
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
770
|
+
setPane("root");
|
|
771
|
+
}
|
|
772
|
+
setOpen(!open);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function toggleGroup(id) {
|
|
776
|
+
const next = {};
|
|
777
|
+
for (const key in expanded) next[key] = expanded[key];
|
|
778
|
+
next[id] = !next[id];
|
|
779
|
+
setExpanded(next);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/* The shipped menu toasts a failed selection instead of closing; mirror
|
|
783
|
+
that — a silent failure reads as "nothing happened / it jumped back". */
|
|
784
|
+
function settleSelect(outcome) {
|
|
785
|
+
if (outcome === true) { setOpen(false); return; }
|
|
786
|
+
toastSeq.current += 1;
|
|
787
|
+
const message = outcome && outcome.code !== undefined
|
|
788
|
+
? outcome.code + ": " + outcome.message
|
|
789
|
+
: String(outcome === false || outcome === undefined ? "unknown error" : outcome);
|
|
790
|
+
setToast({ seq: toastSeq.current, text: t("composer.selectFailed").split("{msg}").join(message) });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function pick(group, model) {
|
|
794
|
+
const sameModel = current !== null && current.provider === group.id && current.model === model.id;
|
|
795
|
+
let effort = model.reasoning ? model.reasoning.defaultEffort : undefined;
|
|
796
|
+
if (sameModel && current.reasoningEffort !== undefined) effort = current.reasoningEffort;
|
|
797
|
+
const selection = { provider: group.id, model: model.id };
|
|
798
|
+
if (effort !== undefined) selection.reasoningEffort = effort;
|
|
799
|
+
Promise.resolve(select(selection)).then(settleSelect);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/* Same model, new effort — the shipped semantics: an unchanged level just
|
|
803
|
+
closes, otherwise the whole selection is resubmitted. */
|
|
804
|
+
function chooseEffort(effort) {
|
|
805
|
+
if (current === null) return;
|
|
806
|
+
if (effectiveEffort === effort) { setOpen(false); return; }
|
|
807
|
+
const selection = { provider: current.provider, model: current.model };
|
|
808
|
+
if (effort !== undefined) selection.reasoningEffort = effort;
|
|
809
|
+
Promise.resolve(select(selection)).then(settleSelect);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const label = currentInfo !== null ? currentInfo.model.name : (current === null ? t("composer.none") : current.provider + "/" + current.model);
|
|
813
|
+
const triggerTitle = effortLabel === undefined ? label : label + " · " + effortLabel;
|
|
814
|
+
const aria = (effortLabel === undefined ? t("composer.aria") : t("composer.ariaEffort"))
|
|
815
|
+
.split("{model}").join(label)
|
|
816
|
+
.split("{effort}").join(effortLabel === undefined ? "" : effortLabel);
|
|
817
|
+
|
|
818
|
+
const chevronIcon = icon("IconChevronDownOutline14", {
|
|
819
|
+
className: C !== null ? cx(C.chevron, open && C.chevronOpen) : undefined,
|
|
820
|
+
style: C === null ? S.caret : undefined
|
|
821
|
+
});
|
|
822
|
+
const triggerChildren = [];
|
|
823
|
+
const triggerGlyph = icon("IconDataOutline16", { key: "g", className: C !== null ? C.triggerIcon : undefined, size: 16 });
|
|
824
|
+
if (triggerGlyph !== null) triggerChildren.push(triggerGlyph);
|
|
825
|
+
triggerChildren.push(h("span", { key: "l", className: C !== null ? C.triggerLabel : undefined, style: C === null ? S.chipLabel : undefined }, label));
|
|
826
|
+
if (effortLabel !== undefined) {
|
|
827
|
+
triggerChildren.push(h("span", { key: "e", className: C !== null ? C.triggerEffort : undefined, style: C === null ? S.chipEffort : undefined }, effortLabel));
|
|
828
|
+
}
|
|
829
|
+
triggerChildren.push(
|
|
830
|
+
busy && isComponent(P !== null ? P.StateDot : null)
|
|
831
|
+
? icon("StateDot", { key: "c", state: "ongoing" })
|
|
832
|
+
: (chevronIcon !== null ? chevronIcon : h("span", { key: "c", style: S.caret }, open ? "▾" : "▴"))
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const trigger = h("button", {
|
|
836
|
+
ref: triggerRef,
|
|
837
|
+
type: "button",
|
|
838
|
+
className: C !== null ? C.trigger : undefined,
|
|
839
|
+
style: C === null ? (disabled ? Object.assign({}, S.chip, S.chipDisabled) : S.chip) : undefined,
|
|
840
|
+
disabled: disabled,
|
|
841
|
+
title: disabled ? t("composer.locked") : triggerTitle,
|
|
842
|
+
"aria-label": aria,
|
|
843
|
+
"aria-haspopup": "menu",
|
|
844
|
+
"aria-expanded": open,
|
|
845
|
+
"aria-controls": open ? idRef.current + "-menu" : undefined,
|
|
846
|
+
onClick: toggle
|
|
847
|
+
}, triggerChildren);
|
|
848
|
+
|
|
849
|
+
/* Keyboard walk mirrors the shipped menu: ↑/↓ cycle the rows of the shown
|
|
850
|
+
pane. (Escape is handled at document level above; Tab quirks are left to
|
|
851
|
+
the browser.) */
|
|
852
|
+
function onRootKeyDown(event) {
|
|
853
|
+
if (!open) return;
|
|
854
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
855
|
+
event.preventDefault();
|
|
856
|
+
const items = itemRefs.current.filter(function (node) {
|
|
857
|
+
return node !== null && node !== undefined && node.disabled !== true;
|
|
858
|
+
});
|
|
859
|
+
if (items.length === 0) return;
|
|
860
|
+
const active = items.indexOf(document.activeElement);
|
|
861
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
862
|
+
const next = active === -1
|
|
863
|
+
? (step === 1 ? 0 : items.length - 1)
|
|
864
|
+
: (active + step + items.length) % items.length;
|
|
865
|
+
items[next].focus();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* The portaled menu must stay a React CHILD of this wrapper: portals
|
|
869
|
+
bubble synthetic events through the REACT tree, not the DOM tree, so
|
|
870
|
+
onKeyDown on the wrapper catches keys pressed inside the menu — exactly
|
|
871
|
+
how the shipped root div wraps trigger + portal. A sibling portal would
|
|
872
|
+
leave the menu's rows keyboard-dead. */
|
|
873
|
+
function rootDiv(children) {
|
|
874
|
+
return h("div", {
|
|
875
|
+
ref: rootRef,
|
|
876
|
+
className: C !== null ? C.root : undefined,
|
|
877
|
+
style: C === null ? { minWidth: 0, position: "relative" } : undefined,
|
|
878
|
+
onKeyDown: onRootKeyDown
|
|
879
|
+
}, children);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (!open) return rootDiv(trigger);
|
|
883
|
+
|
|
884
|
+
const groupNodes = filteredGroups.map(function (group) {
|
|
885
|
+
/* A query forces every group open: the matches must be visible without
|
|
886
|
+
hunting for collapse states; clearing restores the saved state. */
|
|
887
|
+
const isOpen = searchQuery !== "" ? true : expanded[group.id] === true;
|
|
888
|
+
const headingId = idRef.current + "-" + group.id;
|
|
889
|
+
/* A provider row reuses the shipped `cell` drill-in row (label + right-aligned
|
|
890
|
+
value + chevron), so both menu levels are drawn by the shipped sheet. */
|
|
891
|
+
const headChildren = [
|
|
892
|
+
h("span", { key: "n", className: C !== null ? C.cellLabel : undefined, style: C === null ? undefined : undefined }, group.name || group.id),
|
|
893
|
+
h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.groupCount : undefined }, group.models.length + " " + t("composer.count"))
|
|
894
|
+
];
|
|
895
|
+
const groupChevron = icon("IconChevronRightOutline14", {
|
|
896
|
+
key: "i",
|
|
897
|
+
className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
|
|
898
|
+
style: C === null ? S.groupChevron : undefined
|
|
899
|
+
});
|
|
900
|
+
if (groupChevron !== null) headChildren.push(groupChevron);
|
|
901
|
+
const head = h("button", {
|
|
902
|
+
key: "h",
|
|
903
|
+
ref: itemRef(),
|
|
904
|
+
type: "button",
|
|
905
|
+
id: headingId,
|
|
906
|
+
className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
|
|
907
|
+
style: C === null ? S.fallbackGroupTitle : undefined,
|
|
908
|
+
"aria-expanded": isOpen,
|
|
909
|
+
onClick: function () { toggleGroup(group.id); }
|
|
910
|
+
}, headChildren);
|
|
911
|
+
|
|
912
|
+
const items = !isOpen ? [] : group.models.map(function (model) {
|
|
913
|
+
const selected = current !== null && current.provider === group.id && current.model === model.id;
|
|
914
|
+
const check = selected ? icon("IconCheckOutline16", { key: "k" }) : null;
|
|
915
|
+
return h("button", {
|
|
916
|
+
key: model.id,
|
|
917
|
+
ref: itemRef(),
|
|
918
|
+
type: "button",
|
|
919
|
+
role: "menuitemradio",
|
|
920
|
+
"aria-checked": selected,
|
|
921
|
+
className: C !== null ? cx(C.option, "dsh-mo-model", selected && C.selected) : "dsh-mo-model",
|
|
922
|
+
style: C === null ? S.fallbackModel : undefined,
|
|
923
|
+
title: model.name,
|
|
924
|
+
disabled: busy,
|
|
925
|
+
onClick: function () { pick(group, model); }
|
|
926
|
+
},
|
|
927
|
+
h("span", { className: C !== null ? C.optionCopy : undefined, style: C === null ? S.fallbackCopy : undefined },
|
|
928
|
+
h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.chipLabel : undefined }, model.name || model.id)),
|
|
929
|
+
h("span", { className: C !== null ? C.check : undefined, style: C === null ? S.fallbackCheck : undefined }, check)
|
|
930
|
+
);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
return h("section", {
|
|
934
|
+
key: group.id,
|
|
935
|
+
role: "group",
|
|
936
|
+
"aria-labelledby": headingId,
|
|
937
|
+
className: C !== null ? C.group : S.fallbackGroup
|
|
938
|
+
}, [head].concat(items));
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
/* Root pane = the shipped Model / Effort row pair; the "model" pane keeps
|
|
942
|
+
OUR improvement (providers grouped, collapsible, in the saved order) on
|
|
943
|
+
top of the shipped list styling; the "effort" pane is the shipped radio
|
|
944
|
+
list. No back rows: the shipped menu returns with Escape (or Shift+Tab)
|
|
945
|
+
and closes on pick or outside click, and so does this one. */
|
|
946
|
+
const paneChildren = [];
|
|
947
|
+
if (pane === "root") {
|
|
948
|
+
paneChildren.push(h("button", {
|
|
949
|
+
key: "model",
|
|
950
|
+
ref: itemRef(),
|
|
951
|
+
type: "button",
|
|
952
|
+
role: "menuitem",
|
|
953
|
+
className: C !== null ? C.cell : undefined,
|
|
954
|
+
style: C === null ? S.fallbackGroupTitle : undefined,
|
|
955
|
+
onClick: function () { setQuery(""); setPane("model"); }
|
|
956
|
+
},
|
|
957
|
+
h("span", { key: "l", className: C !== null ? C.cellLabel : undefined }, t("composer.menuModel")),
|
|
958
|
+
h("span", { key: "v", className: C !== null ? C.cellValue : undefined }, label),
|
|
959
|
+
icon("IconChevronRightOutline14", { key: "c", className: C !== null ? C.cellChevron : undefined })
|
|
960
|
+
));
|
|
961
|
+
if (reasoning !== undefined) {
|
|
962
|
+
paneChildren.push(h("button", {
|
|
963
|
+
key: "effort",
|
|
964
|
+
ref: itemRef(),
|
|
965
|
+
type: "button",
|
|
966
|
+
role: "menuitem",
|
|
967
|
+
className: C !== null ? C.cell : undefined,
|
|
968
|
+
style: C === null ? S.fallbackGroupTitle : undefined,
|
|
969
|
+
onClick: function () { setPane("effort"); }
|
|
970
|
+
},
|
|
971
|
+
h("span", { key: "l", className: C !== null ? C.cellLabel : undefined }, t("composer.effort")),
|
|
972
|
+
h("span", { key: "v", className: C !== null ? C.cellValue : undefined }, effortLabel),
|
|
973
|
+
icon("IconChevronRightOutline14", { key: "c", className: C !== null ? C.cellChevron : undefined })
|
|
974
|
+
));
|
|
975
|
+
}
|
|
976
|
+
} else if (pane === "effort") {
|
|
977
|
+
if (effortChoices.length === 0) {
|
|
978
|
+
paneChildren.push(h("div", { key: "ee", className: C !== null ? C.empty : undefined, style: C === null ? S.menuEmpty : undefined }, t("composer.effortEmpty")));
|
|
979
|
+
} else {
|
|
980
|
+
paneChildren.push(h("div", {
|
|
981
|
+
key: "eg",
|
|
982
|
+
className: C !== null ? cx(C.groups, "scrollable") : undefined,
|
|
983
|
+
style: C === null ? S.fallbackGroups : undefined
|
|
984
|
+
}, effortChoices.map(function (level) {
|
|
985
|
+
const selected = effectiveEffort === level.effort;
|
|
986
|
+
const check = selected ? icon("IconCheckOutline16", { key: "k" }) : null;
|
|
987
|
+
return h("button", {
|
|
988
|
+
key: level.key,
|
|
989
|
+
ref: itemRef(),
|
|
990
|
+
type: "button",
|
|
991
|
+
role: "menuitemradio",
|
|
992
|
+
"aria-checked": selected,
|
|
993
|
+
className: C !== null ? cx(C.option, selected && C.selected) : undefined,
|
|
994
|
+
style: C === null ? S.fallbackModel : undefined,
|
|
995
|
+
disabled: busy,
|
|
996
|
+
onClick: function () { chooseEffort(level.effort); }
|
|
997
|
+
},
|
|
998
|
+
h("span", { className: C !== null ? C.optionCopy : undefined, style: C === null ? S.fallbackCopy : undefined },
|
|
999
|
+
h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.chipLabel : undefined }, level.label)),
|
|
1000
|
+
h("span", { className: C !== null ? C.check : undefined, style: C === null ? S.fallbackCheck : undefined }, check)
|
|
1001
|
+
);
|
|
1002
|
+
})));
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
if (showSearch) {
|
|
1006
|
+
/* Pinned search row with the shipped styling: the Input primitive plus
|
|
1007
|
+
the search classes; the clear button only rides along with a query. */
|
|
1008
|
+
paneChildren.push(h("div", { key: "sr", className: C.searchRow },
|
|
1009
|
+
h(P.Input, {
|
|
1010
|
+
key: "in",
|
|
1011
|
+
ref: searchRef,
|
|
1012
|
+
className: cx(C.search, searchQuery !== "" && C.searchWithQuery),
|
|
1013
|
+
type: "text",
|
|
1014
|
+
role: "searchbox",
|
|
1015
|
+
"aria-label": t("composer.searchPlaceholder"),
|
|
1016
|
+
placeholder: t("composer.searchPlaceholder"),
|
|
1017
|
+
value: query,
|
|
1018
|
+
onChange: function (event) { setQuery(event.target.value); }
|
|
1019
|
+
}),
|
|
1020
|
+
searchQuery !== "" ? h("button", {
|
|
1021
|
+
key: "clr",
|
|
1022
|
+
type: "button",
|
|
1023
|
+
className: C.searchClear,
|
|
1024
|
+
"aria-label": t("composer.searchClear"),
|
|
1025
|
+
disabled: busy,
|
|
1026
|
+
onClick: function () {
|
|
1027
|
+
setQuery("");
|
|
1028
|
+
if (searchRef.current !== null && searchRef.current.focus) searchRef.current.focus();
|
|
1029
|
+
}
|
|
1030
|
+
}, icon("IconCloseFillRegular", {})) : null
|
|
1031
|
+
));
|
|
1032
|
+
}
|
|
1033
|
+
if (loading) paneChildren.push(h("div", { key: "s", className: C !== null ? C.status : undefined, style: C === null ? S.boxHint : undefined }, t("composer.refreshing")));
|
|
1034
|
+
if (failures.length > 0) {
|
|
1035
|
+
paneChildren.push(h("div", { key: "w", className: C !== null ? C.warning : undefined, style: C === null ? S.failure : undefined },
|
|
1036
|
+
h("span", null, failures.map(function (f) { return f.name + ": " + f.message; }).join(" · "))));
|
|
1037
|
+
}
|
|
1038
|
+
if (filteredGroups.length === 0) {
|
|
1039
|
+
if (!loading) paneChildren.push(h("div", { key: "e", className: C !== null ? C.empty : undefined, style: C === null ? S.menuEmpty : undefined },
|
|
1040
|
+
searchQuery !== "" ? t("composer.searchEmpty") : t("composer.empty")));
|
|
1041
|
+
} else {
|
|
1042
|
+
paneChildren.push(h("div", {
|
|
1043
|
+
key: "g",
|
|
1044
|
+
className: C !== null ? cx(C.groups, "scrollable") : undefined,
|
|
1045
|
+
style: C === null ? S.fallbackGroups : undefined
|
|
1046
|
+
}, groupNodes));
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/* The shipped menus draw their surface through the MenuSurface primitive
|
|
1051
|
+
(translucent fill + backdrop blur + radius; the `_menu` class only adds
|
|
1052
|
+
geometry). Rendering through it is what makes the frost identical — a
|
|
1053
|
+
translucent fill without the blur reads as "see-through", not "frosted".
|
|
1054
|
+
Without the primitive, fall back to resolving the menu token from
|
|
1055
|
+
<body> and painting it inline (the pre-0.2 workaround). */
|
|
1056
|
+
const menuProps = {
|
|
1057
|
+
ref: menuRef,
|
|
1058
|
+
id: idRef.current + "-menu",
|
|
1059
|
+
className: C !== null ? C.menu : undefined,
|
|
1060
|
+
style: menuPos !== null ? menuPos : MEASURE_STYLE,
|
|
1061
|
+
role: "menu",
|
|
1062
|
+
"aria-label": t("composer.title"),
|
|
1063
|
+
"aria-busy": loading || busy
|
|
1064
|
+
};
|
|
1065
|
+
const menu = MENU_SURFACE !== null
|
|
1066
|
+
? h(MENU_SURFACE, menuProps, paneChildren)
|
|
1067
|
+
: h("div", Object.assign({}, menuProps, {
|
|
1068
|
+
style: Object.assign({
|
|
1069
|
+
background: (function () {
|
|
1070
|
+
try {
|
|
1071
|
+
const v = getComputedStyle(document.body).getPropertyValue("--dsw-specific-menu").trim();
|
|
1072
|
+
if (v.length > 0) return v;
|
|
1073
|
+
} catch (e) {}
|
|
1074
|
+
return "#26262a";
|
|
1075
|
+
})(),
|
|
1076
|
+
/* Frost parity with MenuSurface even on this path: the theme's
|
|
1077
|
+
blur token, with the shipped value as fallback. Without it a
|
|
1078
|
+
fallback menu reads see-through next to the shipped ones. */
|
|
1079
|
+
backdropFilter: "var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%))",
|
|
1080
|
+
WebkitBackdropFilter: "var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%))",
|
|
1081
|
+
borderRadius: "var(--dsw-radius-lg, 16px)"
|
|
1082
|
+
},
|
|
1083
|
+
C !== null ? (menuPos !== null ? menuPos : MEASURE_STYLE)
|
|
1084
|
+
: Object.assign({}, S.fallbackMenu, menuPos !== null ? menuPos : MEASURE_STYLE))
|
|
1085
|
+
}), paneChildren);
|
|
1086
|
+
|
|
1087
|
+
const toastNode = toast !== null && P !== null && isComponent(P.Toast)
|
|
1088
|
+
? h(P.Toast, {
|
|
1089
|
+
key: toast.seq,
|
|
1090
|
+
text: toast.text,
|
|
1091
|
+
icon: icon("IconWarningOutlineRegular", {}),
|
|
1092
|
+
anchor: rootRef.current !== null && rootRef.current.closest ? rootRef.current.closest("[data-composer-card]") : null,
|
|
1093
|
+
onDone: function () { setToast(null); }
|
|
1094
|
+
})
|
|
1095
|
+
: null;
|
|
1096
|
+
return rootDiv([trigger, ReactDOM.createPortal(menu, document.body), toastNode]);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/* ------------- B. one provider card's model order editor ------------------ */
|
|
1100
|
+
/* Owner props: { provider, configured, keyConfigured }. Injected: store, namespace, mutate, t. */
|
|
1101
|
+
function ProviderModelOrder(props) {
|
|
1102
|
+
const C = resolveOfficialClasses();
|
|
1103
|
+
ensureOwnStyles(C);
|
|
1104
|
+
const entry = props.provider;
|
|
1105
|
+
const store = props.store;
|
|
1106
|
+
const namespace = props.namespace;
|
|
1107
|
+
const mutate = props.mutate;
|
|
1108
|
+
const t = props.t;
|
|
1109
|
+
|
|
1110
|
+
const snap = useSyncExternalStore(
|
|
1111
|
+
function (fn) { return store.subscribe(fn); },
|
|
1112
|
+
function () { return store.getSnapshot(); }
|
|
1113
|
+
);
|
|
1114
|
+
const view = snap ? snap.view : undefined;
|
|
1115
|
+
const writable = !!(view && view.writable);
|
|
1116
|
+
const nsView = namespaceView(view, namespace);
|
|
1117
|
+
const providerId = entry ? entry.provider : undefined;
|
|
1118
|
+
const layer = providerLayer(nsView);
|
|
1119
|
+
const resolved = (nsView && nsView.value && nsView.value.providers) || {};
|
|
1120
|
+
const row = providerId !== undefined ? (layer[providerId] || resolved[providerId]) : undefined;
|
|
1121
|
+
const models = (row && row.models) || [];
|
|
1122
|
+
const baseIds = models.map(function (m) { return m.id; });
|
|
1123
|
+
const sourceKey = baseIds.join("|");
|
|
1124
|
+
const byId = {};
|
|
1125
|
+
for (let i = 0; i < models.length; i++) byId[models[i].id] = models[i];
|
|
1126
|
+
|
|
1127
|
+
const orderState = useState(null);
|
|
1128
|
+
const order = orderState[0];
|
|
1129
|
+
const setOrder = orderState[1];
|
|
1130
|
+
const savingState = useState(false);
|
|
1131
|
+
const saving = savingState[0];
|
|
1132
|
+
const setSaving = savingState[1];
|
|
1133
|
+
const notePair = useNote();
|
|
1134
|
+
const note = notePair[0];
|
|
1135
|
+
const setNote = notePair[1];
|
|
1136
|
+
const expandState = useState(false);
|
|
1137
|
+
const expanded = expandState[0];
|
|
1138
|
+
const setExpanded = expandState[1];
|
|
1139
|
+
|
|
1140
|
+
useEffect(function () { setOrder(null); }, [providerId, sourceKey]);
|
|
1141
|
+
|
|
1142
|
+
const ids = order === null ? baseIds : order;
|
|
1143
|
+
const dirty = order !== null && order.join("|") !== sourceKey;
|
|
1144
|
+
|
|
1145
|
+
function commit(next) {
|
|
1146
|
+
if (next.join("|") === baseIds.join("|")) { setOrder(null); return; }
|
|
1147
|
+
setOrder(next);
|
|
1148
|
+
if (!writable || saving) return;
|
|
1149
|
+
setSaving(true);
|
|
1150
|
+
const basePath = entry && entry.settingsPath ? entry.settingsPath.slice() : ["providers", providerId];
|
|
1151
|
+
const path = basePath.concat(["models"]);
|
|
1152
|
+
const revision = nsView ? nsView.revision : undefined;
|
|
1153
|
+
let pending;
|
|
1154
|
+
try {
|
|
1155
|
+
pending = mutate([{ op: "set", path: path, value: next.map(function (id) { return byId[id]; }) }], revision);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
setSaving(false);
|
|
1158
|
+
setNote(String((e && e.message) || e));
|
|
1159
|
+
setTimeout(function () { setNote(null); }, 4000);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
Promise.resolve(pending).then(function (res) {
|
|
1163
|
+
setSaving(false);
|
|
1164
|
+
if (res && res.ok) { setOrder(null); setNote("ok"); }
|
|
1165
|
+
else setNote((res && res.error && res.error.message) || "save failed");
|
|
1166
|
+
setTimeout(function () { setNote(null); }, 2600);
|
|
1167
|
+
}, function (err) {
|
|
1168
|
+
setSaving(false);
|
|
1169
|
+
setNote(String((err && err.message) || err));
|
|
1170
|
+
setTimeout(function () { setNote(null); }, 2600);
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/* The embedded list also renders a lone model, so expanding a single-model
|
|
1175
|
+
provider shows what it holds instead of nothing. */
|
|
1176
|
+
const minModels = props.embedded === true ? 1 : 2;
|
|
1177
|
+
if (!entry || models.length < minModels) return null;
|
|
1178
|
+
|
|
1179
|
+
/* Embedded mode: the footer panel owns the surrounding provider row and its
|
|
1180
|
+
expand state, so only the draggable model list is rendered here. */
|
|
1181
|
+
if (props.embedded === true) {
|
|
1182
|
+
return h(DragList, {
|
|
1183
|
+
ids: ids,
|
|
1184
|
+
disabled: !writable || saving,
|
|
1185
|
+
onCommit: commit,
|
|
1186
|
+
render: function (id, index, dragProps) {
|
|
1187
|
+
const model = byId[id];
|
|
1188
|
+
return h("div", {
|
|
1189
|
+
key: id,
|
|
1190
|
+
draggable: dragProps.draggable,
|
|
1191
|
+
"data-mo": "model-row",
|
|
1192
|
+
"data-mo-id": id,
|
|
1193
|
+
"data-mo-provider": String(providerId),
|
|
1194
|
+
tabIndex: -1,
|
|
1195
|
+
onDragStart: dragProps.onDragStart,
|
|
1196
|
+
onDragOver: dragProps.onDragOver,
|
|
1197
|
+
onDragEnd: dragProps.onDragEnd,
|
|
1198
|
+
onDrop: dragProps.onDrop,
|
|
1199
|
+
className: C !== null ? cx(C.option, "dsh-mo-model") : "dsh-mo-model",
|
|
1200
|
+
style: dragProps.dragging
|
|
1201
|
+
? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
|
|
1202
|
+
: (C !== null ? undefined : S.row)
|
|
1203
|
+
},
|
|
1204
|
+
|
|
1205
|
+
h("span", { key: "n", className: C !== null ? C.optionCopy : undefined, style: C === null ? { flex: "1 1 auto", minWidth: 0, display: "flex" } : undefined },
|
|
1206
|
+
h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.rowName : undefined }, (model && (model.name || model.id)) || id))
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/* Header doubles as the collapse control, drawn by the shipped `cell` row.
|
|
1213
|
+
Status notes ride the header's right edge so the list never reflows. */
|
|
1214
|
+
const headChildren = [
|
|
1215
|
+
h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("order.title")),
|
|
1216
|
+
h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.boxHint : undefined }, saving ? t("order.saving") : t("order.hint"))
|
|
1217
|
+
];
|
|
1218
|
+
const notes = [];
|
|
1219
|
+
if (!writable) notes.push(h("span", { key: "ro", style: S.dirty }, t("order.readonly")));
|
|
1220
|
+
if (dirty) notes.push(h("span", { key: "d", style: S.dirty }, t("order.unsaved")));
|
|
1221
|
+
if (note === "ok") notes.push(h("span", { key: "ok", style: S.ok }, t("order.saved")));
|
|
1222
|
+
else if (note !== null) notes.push(h("span", { key: "n", style: S.dirty }, note));
|
|
1223
|
+
for (let i = 0; i < notes.length; i++) headChildren.push(notes[i]);
|
|
1224
|
+
const headChevron = icon("IconChevronRightOutline14", {
|
|
1225
|
+
key: "c",
|
|
1226
|
+
className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
|
|
1227
|
+
style: C === null ? S.groupChevron : undefined
|
|
1228
|
+
});
|
|
1229
|
+
if (headChevron !== null) headChildren.push(headChevron);
|
|
1230
|
+
|
|
1231
|
+
const head = h("button", {
|
|
1232
|
+
type: "button",
|
|
1233
|
+
className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
|
|
1234
|
+
style: C === null ? S.boxHead : undefined,
|
|
1235
|
+
"aria-expanded": expanded,
|
|
1236
|
+
onClick: function () { setExpanded(!expanded); }
|
|
1237
|
+
}, headChildren);
|
|
1238
|
+
|
|
1239
|
+
const list = h(DragList, {
|
|
1240
|
+
ids: ids,
|
|
1241
|
+
disabled: !writable || saving,
|
|
1242
|
+
onCommit: commit,
|
|
1243
|
+
render: function (id, index, dragProps) {
|
|
1244
|
+
const model = byId[id];
|
|
1245
|
+
return h("div", {
|
|
1246
|
+
key: id,
|
|
1247
|
+
draggable: dragProps.draggable,
|
|
1248
|
+
"data-mo": "model-row",
|
|
1249
|
+
"data-mo-id": id,
|
|
1250
|
+
"data-mo-provider": String(providerId),
|
|
1251
|
+
tabIndex: -1,
|
|
1252
|
+
onDragStart: dragProps.onDragStart,
|
|
1253
|
+
onDragOver: dragProps.onDragOver,
|
|
1254
|
+
onDragEnd: dragProps.onDragEnd,
|
|
1255
|
+
onDrop: dragProps.onDrop,
|
|
1256
|
+
className: C !== null ? cx(C.option, "dsh-mo-row") : undefined,
|
|
1257
|
+
style: dragProps.dragging
|
|
1258
|
+
? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
|
|
1259
|
+
: (C !== null ? undefined : S.row)
|
|
1260
|
+
},
|
|
1261
|
+
|
|
1262
|
+
h("span", { key: "n", className: C !== null ? C.optionCopy : undefined, style: C === null ? undefined : { flex: "1 1 auto", minWidth: 0, display: "flex" } },
|
|
1263
|
+
h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.rowName : undefined }, (model && (model.name || model.id)) || id))
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
return h("div", { style: S.box }, head, expanded ? list : null);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/* ------------- C. provider order panel (models page footer) --------------- */
|
|
1272
|
+
/* Provider order is a DISPLAY preference owned by this plugin: the settings
|
|
1273
|
+
document stores `providers` as a record whose key order the host normalises
|
|
1274
|
+
away (verified with both a whole-record `set` and an `unset`+`set` pair), so
|
|
1275
|
+
the order cannot live there. It is kept per browser and applied to the
|
|
1276
|
+
composer menu this plugin renders. */
|
|
1277
|
+
const ORDER_STORAGE_KEY = "dsh-model-organizer.providerOrder";
|
|
1278
|
+
|
|
1279
|
+
function readProviderOrder() {
|
|
1280
|
+
try {
|
|
1281
|
+
const raw = window.localStorage.getItem(ORDER_STORAGE_KEY);
|
|
1282
|
+
if (raw === null) return [];
|
|
1283
|
+
const parsed = JSON.parse(raw);
|
|
1284
|
+
if (!Array.isArray(parsed)) return [];
|
|
1285
|
+
const out = [];
|
|
1286
|
+
for (let i = 0; i < parsed.length; i++) if (typeof parsed[i] === "string") out.push(parsed[i]);
|
|
1287
|
+
return out;
|
|
1288
|
+
} catch (e) { return []; }
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function writeProviderOrder(ids) {
|
|
1292
|
+
try { window.localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)); } catch (e) {}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const PANEL_STORAGE_KEY = "dsh-model-organizer.panelOpen";
|
|
1296
|
+
const PANEL_POS_KEY = "dsh-model-organizer.panelPos";
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
function readPanelPos() {
|
|
1300
|
+
try {
|
|
1301
|
+
const raw = window.localStorage.getItem(PANEL_POS_KEY);
|
|
1302
|
+
if (raw === null) return null;
|
|
1303
|
+
const parsed = JSON.parse(raw);
|
|
1304
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
1305
|
+
if (typeof parsed.left !== "number" || typeof parsed.top !== "number") return null;
|
|
1306
|
+
return { left: parsed.left, top: parsed.top };
|
|
1307
|
+
} catch (e) { return null; }
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function writePanelPos(pos) {
|
|
1311
|
+
try { window.localStorage.setItem(PANEL_POS_KEY, JSON.stringify(pos)); } catch (e) {}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/* ALWAYS expanded on mount. A remembered collapse is indistinguishable from
|
|
1315
|
+
"the default is collapsed" the next time the page opens, which is not what
|
|
1316
|
+
this panel is for. */
|
|
1317
|
+
function readPanelOpen() {
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function writePanelOpen(open) {
|
|
1322
|
+
try { window.localStorage.setItem(PANEL_STORAGE_KEY, open ? "1" : "0"); } catch (e) {}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/* Stored order first, then everything else in document order. */
|
|
1326
|
+
function applyProviderOrder(ids, preferred) {
|
|
1327
|
+
if (preferred.length === 0) return ids;
|
|
1328
|
+
const rank = {};
|
|
1329
|
+
for (let i = 0; i < preferred.length; i++) rank[preferred[i]] = i;
|
|
1330
|
+
const known = [];
|
|
1331
|
+
const rest = [];
|
|
1332
|
+
for (let i = 0; i < ids.length; i++) {
|
|
1333
|
+
if (rank[ids[i]] === undefined) rest.push(ids[i]);
|
|
1334
|
+
else known.push(ids[i]);
|
|
1335
|
+
}
|
|
1336
|
+
known.sort(function (a, b) { return rank[a] - rank[b]; });
|
|
1337
|
+
return known.concat(rest);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/* A seat entry that throws is silently abdicated, so "my panel is missing" and
|
|
1341
|
+
"my panel threw" look identical from the page. This boundary turns the throw
|
|
1342
|
+
into one visible line, so the failure can be reported instead of guessed. */
|
|
1343
|
+
class PanelBoundary extends React.Component {
|
|
1344
|
+
constructor(props) {
|
|
1345
|
+
super(props);
|
|
1346
|
+
this.state = { err: null };
|
|
1347
|
+
}
|
|
1348
|
+
static getDerivedStateFromError(err) {
|
|
1349
|
+
return { err: err };
|
|
1350
|
+
}
|
|
1351
|
+
render() {
|
|
1352
|
+
if (this.state.err !== null) {
|
|
1353
|
+
const message = String((this.state.err && this.state.err.message) || this.state.err);
|
|
1354
|
+
return h("div", {
|
|
1355
|
+
"data-mo": "panel-error",
|
|
1356
|
+
style: {
|
|
1357
|
+
margin: "8px 0", padding: "6px 10px", borderRadius: 8, fontSize: 12, lineHeight: "18px",
|
|
1358
|
+
whiteSpace: "pre-wrap",
|
|
1359
|
+
border: "1px solid var(--dsw-alias-state-error-primary, #f85149)",
|
|
1360
|
+
color: "var(--dsw-alias-state-error-primary, #f85149)"
|
|
1361
|
+
}
|
|
1362
|
+
}, "dsh-model-organizer 渲染失败:" + message);
|
|
1363
|
+
}
|
|
1364
|
+
return this.props.children;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function ProviderOrderPanelGuarded(props) {
|
|
1369
|
+
return h(PanelBoundary, null, h(ProviderOrderPanel, props));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function ProviderGroupedModelSelectGuarded(props) {
|
|
1373
|
+
return h(PanelBoundary, null, h(ProviderGroupedModelSelect, props));
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function ProviderOrderPanel(props) {
|
|
1377
|
+
const C = resolveOfficialClasses();
|
|
1378
|
+
ensureOwnStyles(C);
|
|
1379
|
+
const store = props.store;
|
|
1380
|
+
const namespace = props.namespace;
|
|
1381
|
+
const mutate = props.mutate;
|
|
1382
|
+
const t = props.t;
|
|
1383
|
+
|
|
1384
|
+
const snap = useSyncExternalStore(
|
|
1385
|
+
function (fn) { return store.subscribe(fn); },
|
|
1386
|
+
function () { return store.getSnapshot(); }
|
|
1387
|
+
);
|
|
1388
|
+
const view = snap ? snap.view : undefined;
|
|
1389
|
+
const nsView = namespaceView(view, namespace);
|
|
1390
|
+
const display = orderLayer(nsView) || {};
|
|
1391
|
+
const documentIds = Object.keys(display);
|
|
1392
|
+
const storedState = useState(readProviderOrder);
|
|
1393
|
+
const stored = storedState[0];
|
|
1394
|
+
const setStored = storedState[1];
|
|
1395
|
+
const openState = useState({});
|
|
1396
|
+
const openMap = openState[0];
|
|
1397
|
+
const setOpenMap = openState[1];
|
|
1398
|
+
const panelState = useState(readPanelOpen);
|
|
1399
|
+
const panelOpen = panelState[0];
|
|
1400
|
+
const setPanelOpen = function (next) { writePanelOpen(next); panelState[1](next); };
|
|
1401
|
+
const posState = useState(readPanelPos);
|
|
1402
|
+
const panelPos = posState[0];
|
|
1403
|
+
const setPanelPos = posState[1];
|
|
1404
|
+
const panelRef = useRef(null);
|
|
1405
|
+
const pressRef = useRef(null);
|
|
1406
|
+
const suppressClick = useRef(false);
|
|
1407
|
+
const movingState = useState(false);
|
|
1408
|
+
const moving = movingState[0];
|
|
1409
|
+
const setMoving = movingState[1];
|
|
1410
|
+
const baseIds = applyProviderOrder(documentIds, stored);
|
|
1411
|
+
const sourceKey = baseIds.join("|");
|
|
1412
|
+
|
|
1413
|
+
const orderState = useState(null);
|
|
1414
|
+
const order = orderState[0];
|
|
1415
|
+
const setOrder = orderState[1];
|
|
1416
|
+
const savingState = useState(false);
|
|
1417
|
+
const saving = savingState[0];
|
|
1418
|
+
const setSaving = savingState[1];
|
|
1419
|
+
const notePair = useNote();
|
|
1420
|
+
const note = notePair[0];
|
|
1421
|
+
const setNote = notePair[1];
|
|
1422
|
+
|
|
1423
|
+
useEffect(function () { setOrder(null); }, [sourceKey]);
|
|
1424
|
+
|
|
1425
|
+
/* Mirror the saved order onto the settings page's provider cards: the
|
|
1426
|
+
shipped list is a flex column, so CSS `order` re-ranks it in place.
|
|
1427
|
+
Cards carry no provider identity in the DOM — match through the
|
|
1428
|
+
displayName text each card renders. */
|
|
1429
|
+
const seatRef = useRef(null);
|
|
1430
|
+
const mirrorCards = function () {
|
|
1431
|
+
try {
|
|
1432
|
+
const seat = seatRef.current;
|
|
1433
|
+
if (seat === null) return;
|
|
1434
|
+
/* The seat may sit inside slot wrappers between it and the section —
|
|
1435
|
+
climb until a container actually holds the card list. */
|
|
1436
|
+
let list = null;
|
|
1437
|
+
let el = seat;
|
|
1438
|
+
for (let hop = 0; hop < 6 && el !== null && list === null; hop++) {
|
|
1439
|
+
el = el.parentElement;
|
|
1440
|
+
if (el !== null) list = el.querySelector("ul");
|
|
1441
|
+
}
|
|
1442
|
+
if (list === null) return;
|
|
1443
|
+
const byName = {};
|
|
1444
|
+
for (const id in display) {
|
|
1445
|
+
const profile = display[id] || {};
|
|
1446
|
+
const name = profile.displayName || id;
|
|
1447
|
+
if (byName[name] === undefined) byName[name] = id;
|
|
1448
|
+
}
|
|
1449
|
+
/* No usable identity mapping — leave the cards alone rather than
|
|
1450
|
+
pretending to reorder. */
|
|
1451
|
+
if (Object.keys(byName).length === 0) return;
|
|
1452
|
+
const rank = {};
|
|
1453
|
+
baseIds.forEach(function (id, index) { rank[id] = index + 1; });
|
|
1454
|
+
let unknown = 1000;
|
|
1455
|
+
for (let i = 0; i < list.children.length; i++) {
|
|
1456
|
+
const li = list.children[i];
|
|
1457
|
+
const nameEl = li.querySelector ? li.querySelector('[class*="rowName"]') : null;
|
|
1458
|
+
const name = nameEl !== null ? nameEl.textContent : null;
|
|
1459
|
+
const id = name !== null && byName[name] !== undefined ? byName[name] : null;
|
|
1460
|
+
if (id !== null && rank[id] !== undefined) li.style.order = String(rank[id]);
|
|
1461
|
+
else li.style.order = String(unknown++);
|
|
1462
|
+
}
|
|
1463
|
+
} catch (e) {}
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
useEffect(function () {
|
|
1467
|
+
mirrorCards();
|
|
1468
|
+
const seat = seatRef.current;
|
|
1469
|
+
const container = seat !== null ? seat.parentElement : null;
|
|
1470
|
+
const list = container !== null ? container.querySelector("ul") : null;
|
|
1471
|
+
if (list === null || typeof MutationObserver !== "function") return undefined;
|
|
1472
|
+
const observer = new MutationObserver(function () { mirrorCards(); });
|
|
1473
|
+
observer.observe(list, { childList: true });
|
|
1474
|
+
return function () { observer.disconnect(); };
|
|
1475
|
+
}, [sourceKey, stored]);
|
|
1476
|
+
|
|
1477
|
+
useLayoutEffect(function () {
|
|
1478
|
+
if (panelPos !== null || panelRef.current === null) return;
|
|
1479
|
+
const rect = panelRef.current.getBoundingClientRect();
|
|
1480
|
+
setPanelPos({ left: Math.round(rect.left), top: Math.round(rect.top) });
|
|
1481
|
+
}, [panelPos, panelOpen]);
|
|
1482
|
+
|
|
1483
|
+
const ids = order === null ? baseIds : order;
|
|
1484
|
+
const idsKey = ids.join("|");
|
|
1485
|
+
const dirty = order !== null && order.join("|") !== sourceKey;
|
|
1486
|
+
|
|
1487
|
+
/* The shipped provider cards are left exactly as React renders them. Ordering used
|
|
1488
|
+
to be mirrored onto them with inline `order`; that is gone — this plugin adds a
|
|
1489
|
+
floating panel to the Models page and nothing else. */
|
|
1490
|
+
function commit(next) {
|
|
1491
|
+
if (next.join("|") === baseIds.join("|")) { setOrder(null); return; }
|
|
1492
|
+
writeProviderOrder(next);
|
|
1493
|
+
setStored(next);
|
|
1494
|
+
setOrder(null);
|
|
1495
|
+
setNote("ok");
|
|
1496
|
+
setTimeout(function () { setNote(null); }, 2200);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (baseIds.length < 2) return null;
|
|
1500
|
+
|
|
1501
|
+
const notes = [];
|
|
1502
|
+
if (note === "ok") notes.push(h("span", { key: "ok", style: S.ok }, t("providers.saved")));
|
|
1503
|
+
else if (note !== null) notes.push(h("span", { key: "n", style: S.dirty }, note));
|
|
1504
|
+
|
|
1505
|
+
/* Same shipped-cell header as every other row in this page: the title, the
|
|
1506
|
+
hint, then any status note on the right edge. */
|
|
1507
|
+
const headChildren = [
|
|
1508
|
+
h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("providers.title"))
|
|
1509
|
+
];
|
|
1510
|
+
/* Status notes sit immediately after the title; the hint keeps the right edge. */
|
|
1511
|
+
for (let i = 0; i < notes.length; i++) headChildren.push(notes[i]);
|
|
1512
|
+
headChildren.push(h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.boxHint : undefined }, t("providers.hint")));
|
|
1513
|
+
/* One row, two gestures — exactly like the rows below: a press that does not
|
|
1514
|
+
travel is a CLICK (toggle), a press that travels is a DRAG (move the panel).
|
|
1515
|
+
Arming on MOVEMENT only means a slow click still toggles; pinning the measured
|
|
1516
|
+
top-left on arming keeps the first movement from jumping. */
|
|
1517
|
+
function armMove(token) {
|
|
1518
|
+
if (token.armed) return;
|
|
1519
|
+
token.armed = true;
|
|
1520
|
+
setMoving(true);
|
|
1521
|
+
setPanelPos(token.base);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function moveDown(event) {
|
|
1525
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
1526
|
+
const rect = panelRef.current ? panelRef.current.getBoundingClientRect() : null;
|
|
1527
|
+
const base = rect === null ? { left: 0, top: 0 } : { left: rect.left, top: rect.top };
|
|
1528
|
+
const token = { armed: false, moved: false, base: base, start: { x: event.clientX, y: event.clientY } };
|
|
1529
|
+
pressRef.current = token;
|
|
1530
|
+
try { if (event.currentTarget.setPointerCapture) event.currentTarget.setPointerCapture(event.pointerId); } catch (e) {}
|
|
1531
|
+
try { if (event.currentTarget.blur) event.currentTarget.blur(); } catch (e) {}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function moveMove(event) {
|
|
1535
|
+
const token = pressRef.current;
|
|
1536
|
+
if (token === null) return;
|
|
1537
|
+
if (!token.armed) {
|
|
1538
|
+
const travelled = Math.abs(event.clientX - token.start.x) + Math.abs(event.clientY - token.start.y);
|
|
1539
|
+
if (travelled <= 6) return;
|
|
1540
|
+
armMove(token);
|
|
1541
|
+
}
|
|
1542
|
+
event.preventDefault();
|
|
1543
|
+
token.moved = true;
|
|
1544
|
+
const width = panelRef.current ? panelRef.current.offsetWidth : 380;
|
|
1545
|
+
const height = panelRef.current ? panelRef.current.offsetHeight : 120;
|
|
1546
|
+
const left = Math.min(Math.max(token.base.left + (event.clientX - token.start.x), 4), Math.max(4, window.innerWidth - width - 4));
|
|
1547
|
+
const top = Math.min(Math.max(token.base.top + (event.clientY - token.start.y), 4), Math.max(4, window.innerHeight - height - 4));
|
|
1548
|
+
setPanelPos({ left: left, top: top });
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function moveUp() {
|
|
1552
|
+
const token = pressRef.current;
|
|
1553
|
+
if (token === null) return;
|
|
1554
|
+
if (token.armed) {
|
|
1555
|
+
if (panelRef.current) {
|
|
1556
|
+
const rect = panelRef.current.getBoundingClientRect();
|
|
1557
|
+
writePanelPos({ left: Math.round(rect.left), top: Math.round(rect.top) });
|
|
1558
|
+
}
|
|
1559
|
+
/* The release after a move also emits a click; swallow exactly that one so
|
|
1560
|
+
dragging the panel never collapses it. */
|
|
1561
|
+
suppressClick.current = token.moved === true;
|
|
1562
|
+
} else {
|
|
1563
|
+
/* No travel and no hold: a plain click toggles. */
|
|
1564
|
+
setPanelOpen(!panelOpen);
|
|
1565
|
+
}
|
|
1566
|
+
setMoving(false);
|
|
1567
|
+
pressRef.current = null;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
/* The whole header row is the collapse control (no separate arrow): hover,
|
|
1573
|
+
pointer cursor and keyboard activation all come from this one element. */
|
|
1574
|
+
const head = h("div", {
|
|
1575
|
+
role: "button",
|
|
1576
|
+
tabIndex: 0,
|
|
1577
|
+
className: cx(C !== null ? C.cell : undefined, "dsh-mo-head", "dsh-mo-panelhead", moving ? "dsh-mo-moving" : undefined),
|
|
1578
|
+
style: C !== null ? S.panelHeadCell : S.boxHead,
|
|
1579
|
+
title: panelOpen ? t("providers.collapse") : t("providers.expand"),
|
|
1580
|
+
onPointerDown: moveDown,
|
|
1581
|
+
onPointerMove: moveMove,
|
|
1582
|
+
onPointerUp: moveUp,
|
|
1583
|
+
onPointerCancel: moveUp,
|
|
1584
|
+
onClick: function (event) {
|
|
1585
|
+
if (suppressClick.current) { suppressClick.current = false; return; }
|
|
1586
|
+
if (event && event.stopPropagation) event.stopPropagation();
|
|
1587
|
+
},
|
|
1588
|
+
onKeyDown: function (event) {
|
|
1589
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1590
|
+
event.preventDefault();
|
|
1591
|
+
setPanelOpen(!panelOpen);
|
|
1592
|
+
}
|
|
1593
|
+
}, headChildren);
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
const list = h(DragList, {
|
|
1597
|
+
ids: ids,
|
|
1598
|
+
disabled: false,
|
|
1599
|
+
onCommit: commit,
|
|
1600
|
+
render: function (id, index, dragProps) {
|
|
1601
|
+
const profile = display[id] || {};
|
|
1602
|
+
const count = (profile.models || []).length;
|
|
1603
|
+
const isOpen = openMap[id] === true;
|
|
1604
|
+
const rowChildren = [
|
|
1605
|
+
|
|
1606
|
+
h("span", { key: "n", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.rowName : undefined }, profile.displayName || id),
|
|
1607
|
+
h("span", { key: "c", className: C !== null ? C.cellValue : undefined, style: C === null ? S.rowMeta : undefined }, count + " " + t("composer.count"))
|
|
1608
|
+
];
|
|
1609
|
+
const rowChevron = icon("IconChevronRightOutline14", {
|
|
1610
|
+
key: "v",
|
|
1611
|
+
className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
|
|
1612
|
+
style: C === null ? S.groupChevron : undefined
|
|
1613
|
+
});
|
|
1614
|
+
if (rowChevron !== null) rowChildren.push(rowChevron);
|
|
1615
|
+
|
|
1616
|
+
const row = h("div", {
|
|
1617
|
+
key: "row",
|
|
1618
|
+
draggable: dragProps.draggable,
|
|
1619
|
+
"data-mo": "provider-row",
|
|
1620
|
+
"data-mo-id": id,
|
|
1621
|
+
tabIndex: -1,
|
|
1622
|
+
role: "button",
|
|
1623
|
+
"aria-expanded": isOpen,
|
|
1624
|
+
onDragStart: dragProps.onDragStart,
|
|
1625
|
+
onDragOver: dragProps.onDragOver,
|
|
1626
|
+
onDragEnd: dragProps.onDragEnd,
|
|
1627
|
+
onDrop: dragProps.onDrop,
|
|
1628
|
+
onClick: function () {
|
|
1629
|
+
const next = {};
|
|
1630
|
+
for (const key in openMap) next[key] = openMap[key];
|
|
1631
|
+
next[id] = !(next[id] === true);
|
|
1632
|
+
setOpenMap(next);
|
|
1633
|
+
},
|
|
1634
|
+
className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
|
|
1635
|
+
style: dragProps.dragging
|
|
1636
|
+
? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
|
|
1637
|
+
: (C === null ? S.row : undefined)
|
|
1638
|
+
}, rowChildren);
|
|
1639
|
+
|
|
1640
|
+
if (!isOpen || count < 1) return h("div", { key: id, style: S.providerGroup }, row);
|
|
1641
|
+
return h("div", { key: id, style: S.providerGroup }, row,
|
|
1642
|
+
h(ProviderModelOrder, {
|
|
1643
|
+
key: "models",
|
|
1644
|
+
provider: { provider: id, settingsPath: ["providers", id], settingsNs: namespace },
|
|
1645
|
+
store: store,
|
|
1646
|
+
namespace: namespace,
|
|
1647
|
+
mutate: mutate,
|
|
1648
|
+
t: t,
|
|
1649
|
+
embedded: true
|
|
1650
|
+
})
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
/* The resting place is the bottom-right corner; pinning its measured top-left
|
|
1656
|
+
once keeps that spot while making the panel top-anchored, so collapsing
|
|
1657
|
+
raises the bottom edge instead of dragging the header down. */
|
|
1658
|
+
const panelStyle = panelPos === null
|
|
1659
|
+
? S.floatPanel
|
|
1660
|
+
: Object.assign({}, S.floatPanel, { left: panelPos.left, top: panelPos.top, right: "auto", bottom: "auto" });
|
|
1661
|
+
/* Original layout (header first, list below) with a TOP-anchored panel: the
|
|
1662
|
+
header never moves and collapsing pulls the bottom edge up. */
|
|
1663
|
+
/* Portalled to <body>: the footer seat lives deep inside the settings page, so a
|
|
1664
|
+
fixed panel there is trapped in that subtree's stacking context and any other
|
|
1665
|
+
plugin's floating widget paints over it. At body level the z-index is global. */
|
|
1666
|
+
/* The hidden in-place marker rides the seat (right after the card list),
|
|
1667
|
+
giving the mirror a stable anchor into the settings DOM. */
|
|
1668
|
+
return h(React.Fragment, null,
|
|
1669
|
+
h("div", { ref: seatRef, hidden: true, "data-mo": "seat-marker" }),
|
|
1670
|
+
ReactDOM.createPortal(
|
|
1671
|
+
h("div", { ref: panelRef, style: panelStyle, "data-mo": "panel" },
|
|
1672
|
+
head,
|
|
1673
|
+
panelOpen ? h("div", { style: S.floatBody }, list) : null
|
|
1674
|
+
),
|
|
1675
|
+
document.body
|
|
1676
|
+
)
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/* -------------------------------- helpers --------------------------------- */
|
|
1681
|
+
|
|
1682
|
+
function namespaceView(view, namespace) {
|
|
1683
|
+
if (!view || !view.namespaces) return null;
|
|
1684
|
+
for (let i = 0; i < view.namespaces.length; i++) {
|
|
1685
|
+
if (view.namespaces[i].ns === namespace) return view.namespaces[i];
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/* The user layer carries the values to write back; the resolved value is the
|
|
1691
|
+
fallback for providers configured only by a lower layer. */
|
|
1692
|
+
function providerLayer(nsView) {
|
|
1693
|
+
if (!nsView) return null;
|
|
1694
|
+
return (nsView.user && nsView.user.providers) || (nsView.value && nsView.value.providers) || null;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/* The resolved value keeps the document's real key order; the user section is
|
|
1698
|
+
re-projected by the host and loses it, so ordering reads from `value`. */
|
|
1699
|
+
function orderLayer(nsView) {
|
|
1700
|
+
if (!nsView) return null;
|
|
1701
|
+
return (nsView.value && nsView.value.providers) || (nsView.user && nsView.user.providers) || null;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/* -------------------------------- plugin body ----------------------------- */
|
|
1705
|
+
/* The seat contract resolves its standard props (sessionId, useSession, …) through
|
|
1706
|
+
THIS plugin's own context, so every service those props read must be declared
|
|
1707
|
+
here — otherwise the entry throws during render and is silently abdicated. */
|
|
1708
|
+
const inject = ["locale", "slots", "sessions", "remote", "remote.session", "remote.settings"];
|
|
1709
|
+
|
|
1710
|
+
/* Seat registration is isolated per seat: a future seat rename, a contract change,
|
|
1711
|
+
or another plugin taking the same single/keyed cell must not take the whole
|
|
1712
|
+
plugin down with it (a swallowed error here would also hide the cause, so it
|
|
1713
|
+
is reported through the client logger when one is available). */
|
|
1714
|
+
function guard(ctx, what, fn) {
|
|
1715
|
+
try {
|
|
1716
|
+
return fn();
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
const message = "dsh-model-organizer: could not register " + what + " — " + String((error && error.message) || error);
|
|
1719
|
+
try {
|
|
1720
|
+
if (ctx.logger && typeof ctx.logger.warn === "function") ctx.logger.warn(message);
|
|
1721
|
+
else console.warn(message);
|
|
1722
|
+
} catch (e) {}
|
|
1723
|
+
return function () {};
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function apply(ctx) {
|
|
1728
|
+
try { window.__dshModelOrganizerBuild = BUILD; } catch (e) {}
|
|
1729
|
+
/* A DOM marker proving this bundle actually applied, independent of any seat.
|
|
1730
|
+
Seat registration is silent by design (a throw abdicates the entry), so
|
|
1731
|
+
"is the new code even loaded?" is otherwise unanswerable from the page. */
|
|
1732
|
+
try {
|
|
1733
|
+
if (document.body !== null && document.querySelector('[data-mo="boot"]') === null) {
|
|
1734
|
+
const boot = document.createElement("div");
|
|
1735
|
+
boot.setAttribute("data-mo", "boot");
|
|
1736
|
+
boot.setAttribute("data-mo-build", BUILD);
|
|
1737
|
+
boot.hidden = true;
|
|
1738
|
+
document.body.appendChild(boot);
|
|
1739
|
+
}
|
|
1740
|
+
} catch (e) {}
|
|
1741
|
+
ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-model-organizer: dictionaries");
|
|
1742
|
+
const t = ctx.locale.bind(NS);
|
|
1743
|
+
|
|
1744
|
+
ctx.inject(["slots", "modelDirectories", "sessions"], function (scope) {
|
|
1745
|
+
const models = scope.modelDirectories;
|
|
1746
|
+
const sessions = scope.sessions;
|
|
1747
|
+
scope.slots.inject("conversation.input.model", function () {
|
|
1748
|
+
return guard(scope, "conversation.input.model", function () { return scope.slots.register({
|
|
1749
|
+
name: "conversation.input.model",
|
|
1750
|
+
locale: NS,
|
|
1751
|
+
priority: -1,
|
|
1752
|
+
inject: function () {
|
|
1753
|
+
const first = arguments.length > 0 ? arguments[0] : undefined;
|
|
1754
|
+
const id = first && typeof first === "object" ? first.sessionId : first;
|
|
1755
|
+
const directory = models.directoryFor(id);
|
|
1756
|
+
const available = sessions.subagentAddress(id) === undefined;
|
|
1757
|
+
return {
|
|
1758
|
+
available: available,
|
|
1759
|
+
directory: directory.store,
|
|
1760
|
+
load: function () { if (available) directory.load().catch(function () {}); },
|
|
1761
|
+
select: function (selection) {
|
|
1762
|
+
if (!available) return Promise.resolve(false);
|
|
1763
|
+
/* Failures resolve WITH their error (the directory folds remote
|
|
1764
|
+
rejections into the result) so the menu can surface them —
|
|
1765
|
+
the shipped menu toasts exactly this. */
|
|
1766
|
+
return directory.select(selection).then(function (result) {
|
|
1767
|
+
if (result && result.ok) return true;
|
|
1768
|
+
return result && result.error ? result.error : false;
|
|
1769
|
+
}, function (err) { return err; });
|
|
1770
|
+
},
|
|
1771
|
+
t: t
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
}, ProviderGroupedModelSelectGuarded); });
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
/* The settings mirror moved homes: DSH 0.1.7 exposes it as `configForms`
|
|
1779
|
+
(provided by ui-settings); 0.1.5/0.1.6 exposed the same object as
|
|
1780
|
+
`settingsScope`. A cordis `ctx.inject` whose service never appears simply
|
|
1781
|
+
never runs, so injecting only the old name made this whole seat vanish
|
|
1782
|
+
without a single error once DSH renamed it. Inject both names and mount
|
|
1783
|
+
once — whichever resolves first wins, the other stays dormant. */
|
|
1784
|
+
let orderPanelMounted = false;
|
|
1785
|
+
|
|
1786
|
+
function mountOrderPanel(scope, mirror) {
|
|
1787
|
+
if (orderPanelMounted) return;
|
|
1788
|
+
orderPanelMounted = true;
|
|
1789
|
+
/* Mark that the service resolved and the panel is being mounted — this
|
|
1790
|
+
separates "the seat never rendered" from "the inject never fired". */
|
|
1791
|
+
try {
|
|
1792
|
+
if (document.body !== null && document.querySelector('[data-mo="panel-mounted"]') === null) {
|
|
1793
|
+
const mark = document.createElement("div");
|
|
1794
|
+
mark.setAttribute("data-mo", "panel-mounted");
|
|
1795
|
+
mark.hidden = true;
|
|
1796
|
+
document.body.appendChild(mark);
|
|
1797
|
+
}
|
|
1798
|
+
} catch (e) {}
|
|
1799
|
+
mirror.ensure();
|
|
1800
|
+
const mutate = function (ops, revision) { return scope.remote.settings.mutate(PI_AI_NS, ops, revision); };
|
|
1801
|
+
|
|
1802
|
+
const face = function () {
|
|
1803
|
+
return { store: mirror, namespace: PI_AI_NS, mutate: mutate, t: t };
|
|
1804
|
+
};
|
|
1805
|
+
|
|
1806
|
+
/* NOTE: `settings.models.provider-card` is deliberately NOT taken. The owner
|
|
1807
|
+
dispatches it keyed by the provider's settings namespace, and
|
|
1808
|
+
@linxin666/dsh-client-ui-model-capabilities already registers that exact
|
|
1809
|
+
key; a keyed cell holds ONE occupant, so registering here throws and
|
|
1810
|
+
silently suppresses their "模型能力" panel. This plugin stays in the
|
|
1811
|
+
footer seat it owns instead. */
|
|
1812
|
+
|
|
1813
|
+
scope.slots.inject("settings.models.footer", function () {
|
|
1814
|
+
return guard(scope, "settings.models.footer", function () {
|
|
1815
|
+
return scope.slots.register({
|
|
1816
|
+
name: "settings.models.footer",
|
|
1817
|
+
id: "model-organizer-provider-order",
|
|
1818
|
+
order: 100,
|
|
1819
|
+
inject: face
|
|
1820
|
+
}, ProviderOrderPanelGuarded);
|
|
1821
|
+
});
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
ctx.inject(["slots", "configForms", "remote", "remote.settings"], function (scope) {
|
|
1826
|
+
mountOrderPanel(scope, scope.configForms.describe());
|
|
1827
|
+
});
|
|
1828
|
+
ctx.inject(["slots", "settingsScope", "remote", "remote.settings"], function (scope) {
|
|
1829
|
+
mountOrderPanel(scope, scope.settingsScope.describe());
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
module.exports = { name: "model-organizer", apply: apply, inject: inject };
|
|
1834
|
+
return module.exports;
|
|
1835
|
+
}
|
|
1836
|
+
});
|