dsh-model-flatmap 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/README.zh.md +98 -0
- package/client.js +2378 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +220 -0
- package/docs/model-picker.jpeg +0 -0
- package/icon.svg +11 -0
- package/index.js +12 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +26 -4
package/client.js
ADDED
|
@@ -0,0 +1,2378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat model picker — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the composer's `conversation.input.model` seat with ONE flat panel:
|
|
5
|
+
* search, a provider/family grouping switch, the whole model grid, the current
|
|
6
|
+
* model's reasoning levels, and the agent-teamwork switch — no second-level
|
|
7
|
+
* menus anywhere.
|
|
8
|
+
*
|
|
9
|
+
* Everything is read and written through public Client APIs only:
|
|
10
|
+
* - `ctx.modelDirectories.directoryFor(sessionId)` → the per-Session directory
|
|
11
|
+
* (`store` for data + change subscription, `load()`, `select()`);
|
|
12
|
+
* - `ctx.get('remote').agentTeams.setEnabled()` → the teamwork selection;
|
|
13
|
+
* - `ctx.locale` → copy, catalog badge text resolution, and the host's own
|
|
14
|
+
* wording for the teamwork row;
|
|
15
|
+
* - the slot's standard props (`sessionId`, `useProjection`, `useSession`,
|
|
16
|
+
* `useSessions`) → Session state.
|
|
17
|
+
*
|
|
18
|
+
* Registration uses a negative priority so this entry shadows the shipped
|
|
19
|
+
* `ModelSelect` while it is active; disabling the plugin restores the native
|
|
20
|
+
* control with no residue. The shipped entry keeps its own child-slot
|
|
21
|
+
* declarations (`conversation.input.model.label` / `.options`) alive, so no
|
|
22
|
+
* other plugin's registration is invalidated by this replacement; this panel
|
|
23
|
+
* simply does not host them, and draws the catalog's own model badge instead.
|
|
24
|
+
* No host component, stylesheet or DOM is read.
|
|
25
|
+
*/
|
|
26
|
+
window.__ModuleLoader__.load({
|
|
27
|
+
id: 'dsh-model-flatmap',
|
|
28
|
+
factory(require) {
|
|
29
|
+
const React = require('react')
|
|
30
|
+
const ReactDOM = require('react-dom')
|
|
31
|
+
const h = React.createElement
|
|
32
|
+
const { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } = React
|
|
33
|
+
const useSyncExternalStore = React.useSyncExternalStore
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------
|
|
36
|
+
// Stylesheet — theme tokens only. Inserted once per module generation with
|
|
37
|
+
// the same data-plugin tags the host uses, so disposal is owned upstream.
|
|
38
|
+
// ---------------------------------------------------------------------
|
|
39
|
+
const CSS = `
|
|
40
|
+
.dshmf-root { position: relative; min-width: 0; }
|
|
41
|
+
|
|
42
|
+
/* Trigger: the shipped composer seat's chrome (13/20 regular, 28px chip,
|
|
43
|
+
rounded-sm), so the control reads as the button it replaces. */
|
|
44
|
+
.dshmf-trigger {
|
|
45
|
+
display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 360px;
|
|
46
|
+
height: 28px; padding: 0 4px 0 8px; border: none; border-radius: var(--dsw-radius-sm);
|
|
47
|
+
outline: none; background: transparent; color: var(--dsw-alias-label-secondary);
|
|
48
|
+
font-size: 13px; line-height: 20px; font-weight: 400; cursor: pointer;
|
|
49
|
+
}
|
|
50
|
+
@media (hover: hover) { .dshmf-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } }
|
|
51
|
+
.dshmf-trigger:focus-visible:not([data-selection-focus]) {
|
|
52
|
+
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
53
|
+
}
|
|
54
|
+
.dshmf-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
55
|
+
.dshmf-triggerIcon { display: var(--dsh-composer-model-icon-display, none); flex: 0 0 auto; }
|
|
56
|
+
.dshmf-triggerText { display: var(--dsh-composer-model-text-display, block); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
57
|
+
.dshmf-triggerEffort { flex-shrink: 1000; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-caption); }
|
|
58
|
+
.dshmf-chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; }
|
|
59
|
+
.dshmf-chevronOpen { transform: rotate(180deg); }
|
|
60
|
+
|
|
61
|
+
/* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
|
|
62
|
+
corners), capped at 320px; only the grid scrolls. */
|
|
63
|
+
.dshmf-panel {
|
|
64
|
+
box-sizing: border-box; position: fixed; z-index: 1100;
|
|
65
|
+
display: flex; flex-direction: column; gap: 4px; padding: 6px;
|
|
66
|
+
max-height: 320px; overflow: hidden;
|
|
67
|
+
border: 0; border-radius: var(--dsw-radius-lg);
|
|
68
|
+
background: var(--dsw-menu-surface-fill);
|
|
69
|
+
-webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
70
|
+
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
71
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
72
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
73
|
+
color: var(--dsw-alias-label-primary);
|
|
74
|
+
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
75
|
+
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Fixed sections. */
|
|
79
|
+
.dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
|
|
80
|
+
.dshmf-search { flex: 1 1 70%; display: flex; align-items: center; gap: 4px; min-width: 0; height: 24px; padding: 0 6px; border-radius: var(--dsw-radius-sm); }
|
|
81
|
+
.dshmf-search:focus-within { background: var(--dsw-alias-interactive-bg-hover); }
|
|
82
|
+
.dshmf-input {
|
|
83
|
+
flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0;
|
|
84
|
+
background: transparent; color: var(--dsw-alias-label-primary);
|
|
85
|
+
font-family: inherit; font-size: 12px; line-height: 16px;
|
|
86
|
+
}
|
|
87
|
+
.dshmf-input::placeholder { color: var(--dsw-alias-label-caption); }
|
|
88
|
+
@media (pointer: coarse) { .dshmf-input { font-size: 16px; } }
|
|
89
|
+
.dshmf-clear {
|
|
90
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
|
91
|
+
width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; corner-shape: round;
|
|
92
|
+
background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
.dshmf-clear:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
95
|
+
.dshmf-clear:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -1px; }
|
|
96
|
+
|
|
97
|
+
.dshmf-spin {
|
|
98
|
+
width: 12px; height: 12px; flex: 0 0 auto; border-radius: 50%; corner-shape: round;
|
|
99
|
+
border: 1.5px solid var(--dsw-alias-border-l3); border-top-color: var(--dsw-alias-label-tertiary);
|
|
100
|
+
animation: dshmf-spin 700ms linear infinite;
|
|
101
|
+
}
|
|
102
|
+
@keyframes dshmf-spin { to { transform: rotate(360deg); } }
|
|
103
|
+
|
|
104
|
+
.dshmf-seg { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover); }
|
|
105
|
+
.dshmf-segBtn {
|
|
106
|
+
height: 22px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-xs);
|
|
107
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
108
|
+
font-family: inherit; font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer;
|
|
109
|
+
transition: color 120ms ease, background 120ms ease;
|
|
110
|
+
}
|
|
111
|
+
.dshmf-segBtn[aria-pressed="true"] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-elevation-soft); }
|
|
112
|
+
@media (hover: hover) { .dshmf-segBtn:hover:not([aria-pressed="true"]) { color: var(--dsw-alias-label-primary); } }
|
|
113
|
+
.dshmf-segBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
|
|
114
|
+
|
|
115
|
+
.dshmf-error {
|
|
116
|
+
flex: 0 0 auto; display: flex; align-items: flex-start; gap: 6px; padding: 4px 7px;
|
|
117
|
+
border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover-danger);
|
|
118
|
+
color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* The only scrolling region. */
|
|
122
|
+
.dshmf-grid { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; display: grid; align-items: start; }
|
|
123
|
+
.dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
124
|
+
/* When more than one column is laid out, separate adjacent columns with a
|
|
125
|
+
faint vertical hairline so the eye can tell the dense model flows apart.
|
|
126
|
+
The line uses the lightest border token to stay subtle, and the divided
|
|
127
|
+
column keeps a little right padding so model text never hugs the line.
|
|
128
|
+
Only columns that actually sit before another one get it (see renderColumn),
|
|
129
|
+
so a single-column layout stays borderless. */
|
|
130
|
+
.dshmf-col--divided { border-right: 1px solid var(--dsw-alias-border-l3); padding-right: 10px; }
|
|
131
|
+
/* Provider / family label: a filled, rounded band that reads as a section
|
|
132
|
+
header, clearly separated from the model rows below it. Bare host
|
|
133
|
+
group-header fill is only a few levels from the translucent panel surface.
|
|
134
|
+
The band floats on an opaque grey overlay surface with soft elevation — the
|
|
135
|
+
same recipe the active segment uses. It deliberately steps AWAY from the
|
|
136
|
+
transparent panel surface in both themes: in light it resolves to a soft
|
|
137
|
+
grey (#e9ecf2) that is visibly darker than the white panel, and in dark it
|
|
138
|
+
resolves to a light grey (#61666b) that is visibly lighter than the
|
|
139
|
+
near-black surface — so the band reads as a distinct section header with
|
|
140
|
+
no hard-coded colour and no second theme branch (see DEC-005). Smaller but
|
|
141
|
+
bold type (and the primary label colour) sets it apart from the 13px
|
|
142
|
+
regular row text without borrowing from the row's interactive states.
|
|
143
|
+
A hairline frame plus a short grey (border-level-3) left edge marks each
|
|
144
|
+
group start: visible, never loud, and theme-neutral — no colour accent. The rows themselves
|
|
145
|
+
stay borderless, so the dense flow across columns is kept (see DEC-003,
|
|
146
|
+
DEC-005). */
|
|
147
|
+
.dshmf-groupTitle {
|
|
148
|
+
align-self: flex-start; max-width: 100%;
|
|
149
|
+
box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
|
|
150
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
151
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
152
|
+
border-left: 3px solid var(--dsw-alias-border-l3);
|
|
153
|
+
border-radius: var(--dsw-radius-xs);
|
|
154
|
+
background: var(--dsw-alias-bg-overlay);
|
|
155
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
156
|
+
color: var(--dsw-alias-label-primary);
|
|
157
|
+
font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
|
|
158
|
+
}
|
|
159
|
+
.dshmf-row {
|
|
160
|
+
box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
|
|
161
|
+
height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
|
|
162
|
+
background: transparent; color: var(--dsw-alias-label-primary);
|
|
163
|
+
font-family: inherit; font-size: 13px; line-height: 18px; font-weight: 400; text-align: left;
|
|
164
|
+
cursor: pointer; min-width: 0;
|
|
165
|
+
}
|
|
166
|
+
@media (hover: hover) { .dshmf-row:hover:not(:disabled):not([aria-pressed="true"]) { background: var(--dsw-alias-interactive-bg-hover); } }
|
|
167
|
+
.dshmf-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
|
|
168
|
+
.dshmf-row:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
169
|
+
/* Selected model: a solid deep-blue band with white bold text — the row
|
|
170
|
+
itself says "this one is active", so no checkmark and no reserved tail
|
|
171
|
+
column is needed (that 14px sat empty on every unselected row).
|
|
172
|
+
The pairing is pinned to the static deepseek-600 + white rather than the
|
|
173
|
+
alias --dsw-alias-state-business-primary, because that alias flips to a
|
|
174
|
+
LIGHT blue with dark text in the dark theme; the request is a deep blue
|
|
175
|
+
with white text in both themes, and these two static tokens are the same
|
|
176
|
+
in light and dark. */
|
|
177
|
+
.dshmf-row[aria-pressed="true"],
|
|
178
|
+
.dshmf-row[aria-pressed="true"]:focus-visible {
|
|
179
|
+
background: var(--dsw-static-deepseek-600);
|
|
180
|
+
color: var(--dsw-static-neutral-bluish-00);
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
}
|
|
183
|
+
.dshmf-row[aria-pressed="true"] .dshmf-rowProvider { color: inherit; opacity: .82; }
|
|
184
|
+
.dshmf-row[aria-pressed="true"] .dshmf-badge {
|
|
185
|
+
background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
|
|
186
|
+
color: inherit;
|
|
187
|
+
}
|
|
188
|
+
.dshmf-rowName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
189
|
+
/* The model's last call failed on insufficient balance / credit / quota, and no
|
|
190
|
+
later call for the same model has cleared it. The row text is struck through
|
|
191
|
+
rather than recoloured: the strike survives both themes and the selected
|
|
192
|
+
row's blue band, and it does not compete with the error strip's own red.
|
|
193
|
+
The decoration is applied to the row's text only (name + variant tag), not
|
|
194
|
+
to the badge chip or the spinner. */
|
|
195
|
+
.dshmf-row[data-insufficient="true"] .dshmf-rowName,
|
|
196
|
+
.dshmf-row[data-insufficient="true"] .dshmf-rowProvider {
|
|
197
|
+
text-decoration: line-through;
|
|
198
|
+
text-decoration-thickness: 1px;
|
|
199
|
+
}
|
|
200
|
+
/* A multiplier marker lifted out of the model name (rate/size tags like
|
|
201
|
+
"x0.01", "x1.5", "X 2.2"). The characters stay in place and the full name is
|
|
202
|
+
unchanged; only this run is drawn two sizes smaller and bold, so the
|
|
203
|
+
multiplier reads as an aside without disturbing the row's text rhythm
|
|
204
|
+
(see renderRow's nameRuns). */
|
|
205
|
+
.dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
|
|
206
|
+
/* Secondary text in a family-grouped row. The primary text is the provider,
|
|
207
|
+
so this is only a short variant tail shown when one provider offers several
|
|
208
|
+
models in the same family. Quieter than the primary text (caption colour,
|
|
209
|
+
11px) and shrinks before it does. No width cap: a cap truncates a perfectly
|
|
210
|
+
readable label ("CodeBuddy …") in a column that had room for it. */
|
|
211
|
+
.dshmf-rowProvider {
|
|
212
|
+
flex: 0 1 auto; min-width: 0;
|
|
213
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
214
|
+
color: var(--dsw-alias-label-caption);
|
|
215
|
+
font-size: 11px; line-height: 16px; font-weight: 400;
|
|
216
|
+
}
|
|
217
|
+
.dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
|
|
218
|
+
|
|
219
|
+
/* Catalog badge (for example a third-party rate multiplier). Rendered only
|
|
220
|
+
when the catalog publishes text, so a badge-less model keeps no gap. */
|
|
221
|
+
.dshmf-badge {
|
|
222
|
+
flex: none; display: inline-flex; align-items: center; padding: 0 6px;
|
|
223
|
+
border-radius: 999px; corner-shape: round;
|
|
224
|
+
font-size: 10px; line-height: 16px; font-weight: 500; white-space: nowrap;
|
|
225
|
+
background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary);
|
|
226
|
+
}
|
|
227
|
+
.dshmf-badge[data-tone="info"] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); color: var(--dsw-alias-state-business-primary); }
|
|
228
|
+
.dshmf-badge[data-tone="success"] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
|
|
229
|
+
.dshmf-badge[data-tone="warning"] { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); color: var(--dsw-alias-state-warn-primary); }
|
|
230
|
+
|
|
231
|
+
.dshmf-empty { grid-column: 1 / -1; padding: 10px 8px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
|
232
|
+
.dshmf-failure { grid-column: 1 / -1; padding: 2px 6px; color: var(--dsw-alias-state-warn-label); font-size: 11px; line-height: 16px; overflow-wrap: anywhere; }
|
|
233
|
+
|
|
234
|
+
/* Adaptive footer: the reasoning group and the teamwork group share one line
|
|
235
|
+
when BOTH fit side by side with a guaranteed 30px gap, and otherwise fall
|
|
236
|
+
back to one row each (the original two-line look). The decision is pure CSS:
|
|
237
|
+
the footer stretches to the panel width, each child keeps its natural
|
|
238
|
+
max-content width (flex 0 0 auto), and flex-wrap plus a 30px column-gap
|
|
239
|
+
puts them on one line exactly when reason + 30 + team <= panel width.
|
|
240
|
+
No JS width guessing, so it stays correct across panel resize, theme and
|
|
241
|
+
font changes. A max-width of 100% lets a child that ends up alone on a line
|
|
242
|
+
cap to the panel width and wrap internally instead of overflowing.
|
|
243
|
+
The teamwork row carries no divider of its own (DEC-011 keeps the single
|
|
244
|
+
grey rule above the reasoning group only), so a shared line needs no
|
|
245
|
+
border juggling. */
|
|
246
|
+
.dshmf-footer {
|
|
247
|
+
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
248
|
+
column-gap: 30px; row-gap: 4px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* Reasoning levels: a caption and a multi-segmentation control on one line;
|
|
252
|
+
the track wraps below the caption only when the row cannot fit. The
|
|
253
|
+
segment visual mirrors the header's grouping control, so the panel has
|
|
254
|
+
one visual language for "one of many, exactly one active". */
|
|
255
|
+
/* One obvious grey divider between the scrolling model grid and the fixed
|
|
256
|
+
reasoning row. Host border tokens are faint by design, so this line uses
|
|
257
|
+
explicit greys: medium grey on the light panel, a lighter near-white grey
|
|
258
|
+
on the dark panel. The teamwork row below has no line (DEC-011). */
|
|
259
|
+
.dshmf-reason {
|
|
260
|
+
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
261
|
+
gap: 2px 6px; max-width: 100%;
|
|
262
|
+
border-top: 1px solid #9CA3AF;
|
|
263
|
+
padding-top: 6px;
|
|
264
|
+
}
|
|
265
|
+
body[data-ds-dark-theme] .dshmf-reason,
|
|
266
|
+
html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
|
|
267
|
+
.dshmf-reasonLabel {
|
|
268
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
color: var(--dsw-alias-label-tertiary);
|
|
271
|
+
font-size: 11px; line-height: 16px; font-weight: 500;
|
|
272
|
+
}
|
|
273
|
+
.dshmf-reasonLabel svg { flex: 0 0 auto; }
|
|
274
|
+
.dshmf-pills {
|
|
275
|
+
flex: 0 0 auto; max-width: 100%;
|
|
276
|
+
display: inline-flex; align-items: center; gap: 2px;
|
|
277
|
+
padding: 2px;
|
|
278
|
+
border-radius: var(--dsw-radius-sm);
|
|
279
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
280
|
+
overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
|
|
281
|
+
}
|
|
282
|
+
.dshmf-pills::-webkit-scrollbar { height: 0; }
|
|
283
|
+
.dshmf-pill {
|
|
284
|
+
flex: 0 0 auto; height: 22px; padding: 0 8px; border: 0;
|
|
285
|
+
border-radius: var(--dsw-radius-xs);
|
|
286
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
287
|
+
font-family: inherit; font-size: 11px; line-height: 16px; font-weight: 400; white-space: nowrap; cursor: pointer;
|
|
288
|
+
transition: color 120ms ease, background 120ms ease;
|
|
289
|
+
}
|
|
290
|
+
@media (hover: hover) { .dshmf-pill:hover:not(:disabled):not([aria-checked="true"]) { color: var(--dsw-alias-label-primary); } }
|
|
291
|
+
.dshmf-pill[aria-checked="true"] {
|
|
292
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
293
|
+
box-shadow: var(--dsw-elevation-soft); font-weight: 500;
|
|
294
|
+
}
|
|
295
|
+
.dshmf-pill:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
296
|
+
.dshmf-pill:disabled[aria-checked="true"] { opacity: .5; }
|
|
297
|
+
.dshmf-pill:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
|
|
298
|
+
|
|
299
|
+
/* Teamwork row: label, info button and switch all left-aligned on one line,
|
|
300
|
+
so the switch sits immediately to the right of the title. */
|
|
301
|
+
.dshmf-team {
|
|
302
|
+
flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
|
303
|
+
max-width: 100%;
|
|
304
|
+
min-height: 30px; padding: 0 6px;
|
|
305
|
+
color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px;
|
|
306
|
+
}
|
|
307
|
+
.dshmf-teamLabel { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
|
|
308
|
+
.dshmf-teamName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
309
|
+
.dshmf-info {
|
|
310
|
+
flex: none; display: inline-flex; align-items: center; justify-content: center;
|
|
311
|
+
width: 16px; height: 16px; color: var(--dsw-alias-label-caption);
|
|
312
|
+
}
|
|
313
|
+
.dshmf-switch {
|
|
314
|
+
box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px;
|
|
315
|
+
border: 0; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer;
|
|
316
|
+
}
|
|
317
|
+
.dshmf-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
|
|
318
|
+
.dshmf-switch:disabled { cursor: default; opacity: .5; }
|
|
319
|
+
.dshmf-switch:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
320
|
+
.dshmf-thumb { display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-label-primary-foreground); transition: transform .12s ease; }
|
|
321
|
+
.dshmf-switch[aria-checked="false"] .dshmf-thumb { background: var(--dsw-alias-switch-thumb); }
|
|
322
|
+
.dshmf-switch[aria-checked="true"] .dshmf-thumb { transform: translate(16px); }
|
|
323
|
+
|
|
324
|
+
/* Inline disable confirmation, with the host's confirmation copy. */
|
|
325
|
+
.dshmf-confirm { flex: 0 0 auto; display: flex; flex-direction: column; gap: 3px; padding: 6px 8px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-module-platform); }
|
|
326
|
+
.dshmf-confirmTitle { color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; font-weight: 500; }
|
|
327
|
+
.dshmf-confirmBody { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
|
|
328
|
+
.dshmf-confirmActions { display: flex; justify-content: flex-end; gap: 6px; }
|
|
329
|
+
.dshmf-confirmBtn { height: 22px; padding: 0 9px; border: 0; border-radius: var(--dsw-radius-xs); background: transparent; color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
|
|
330
|
+
.dshmf-confirmBtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
331
|
+
.dshmf-confirmBtn[data-primary="true"] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
|
|
332
|
+
.dshmf-confirmBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
|
|
333
|
+
|
|
334
|
+
/* Off-screen probe: measures one group title and one model row at the real
|
|
335
|
+
font scale, so column fit uses this browser's metrics, not a guess. */
|
|
336
|
+
.dshmf-probe { position: absolute; top: 0; left: 0; width: 160px; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; }
|
|
337
|
+
|
|
338
|
+
@media (prefers-reduced-motion: reduce) {
|
|
339
|
+
.dshmf-spin { animation-duration: 2s; }
|
|
340
|
+
.dshmf-thumb, .dshmf-chevron, .dshmf-pill, .dshmf-segBtn { transition: none; }
|
|
341
|
+
}
|
|
342
|
+
`
|
|
343
|
+
const STYLE_TAG = 'dsh-model-flatmap/panel.css'
|
|
344
|
+
const STYLE_OWNER = 'dsh-model-flatmap'
|
|
345
|
+
if (typeof document !== 'undefined'
|
|
346
|
+
&& document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG)}]`) === null) {
|
|
347
|
+
const tag = document.createElement('style')
|
|
348
|
+
tag.dataset.plugin = STYLE_OWNER
|
|
349
|
+
tag.dataset.pluginCss = STYLE_TAG
|
|
350
|
+
tag.textContent = CSS
|
|
351
|
+
document.head.append(tag)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------
|
|
355
|
+
// Constants and helpers
|
|
356
|
+
// ---------------------------------------------------------------------
|
|
357
|
+
const PANEL_MAX = 320
|
|
358
|
+
const PANEL_PAD = 6
|
|
359
|
+
const PANEL_GAP = 4
|
|
360
|
+
const COL_GAP = 10
|
|
361
|
+
const ROW_GAP = 2
|
|
362
|
+
const BALANCE_THRESHOLD = 24
|
|
363
|
+
|
|
364
|
+
const PATH_CHEVRON = 'M3.2 5.6 7 9.4 10.8 5.6'
|
|
365
|
+
const PATH_CLOSE = 'M4 4 10 10M10 4 4 10'
|
|
366
|
+
const PATH_INFO = 'M7 6.4v4.1M7 3.7v.8'
|
|
367
|
+
const PATH_SPARK = 'M7 1.6 8.3 5.7 12.4 7 8.3 8.3 7 12.4 5.7 8.3 1.6 7 5.7 5.7 7 1.6Z'
|
|
368
|
+
const PATH_MODELS = 'M7 2.1 11.9 4.7 7 7.3 2.1 4.7 7 2.1Zm-4.9 5.1L7 9.8l4.9-2.6M2.1 10.3 7 12.9l4.9-2.6'
|
|
369
|
+
|
|
370
|
+
function svg(path, size, extra) {
|
|
371
|
+
return h('svg', {
|
|
372
|
+
width: size, height: size, viewBox: '0 0 14 14', fill: 'none',
|
|
373
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
374
|
+
'aria-hidden': 'true', focusable: 'false', ...extra,
|
|
375
|
+
}, h('path', { d: path }))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Resolve provider-authored catalog text without the locale service. */
|
|
379
|
+
function fallbackText(value) {
|
|
380
|
+
return typeof value === 'string' ? value : (value?.en ?? '')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function fill(t, key, values) {
|
|
384
|
+
const template = String(t(key))
|
|
385
|
+
if (values === undefined) return template
|
|
386
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ''))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Split a raw query into lowercase search tokens. Whitespace separates
|
|
391
|
+
* tokens, so `mod inte` asks for `mod` AND `inte` rather than for that
|
|
392
|
+
* literal string — which is what lets it find `ModelScope International`,
|
|
393
|
+
* a name the two words never appear in contiguously.
|
|
394
|
+
* @param {string} query - raw search text.
|
|
395
|
+
* @returns {string[]} lowercase, non-empty tokens.
|
|
396
|
+
*/
|
|
397
|
+
function searchTokens(query) {
|
|
398
|
+
const raw = typeof query === 'string' ? query : ''
|
|
399
|
+
return raw.trim().toLowerCase().split(/\s+/).filter(token => token !== '')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rank ONE token against one name: prefix beats substring beats ordered
|
|
404
|
+
* subsequence. This is the original single-term ranking, unchanged.
|
|
405
|
+
* @param {string} name - lowercase haystack.
|
|
406
|
+
* @param {string} token - lowercase needle.
|
|
407
|
+
* @returns {0|1|2|null} the rank, or null when the token is absent.
|
|
408
|
+
*/
|
|
409
|
+
function tokenScore(name, token) {
|
|
410
|
+
if (name.startsWith(token)) return 0
|
|
411
|
+
if (name.includes(token)) return 1
|
|
412
|
+
let at = 0
|
|
413
|
+
for (const ch of token) {
|
|
414
|
+
at = name.indexOf(ch, at)
|
|
415
|
+
if (at === -1) return null
|
|
416
|
+
at += 1
|
|
417
|
+
}
|
|
418
|
+
return 2
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Case-insensitive match: prefix beats substring beats ordered subsequence.
|
|
423
|
+
*
|
|
424
|
+
* A query may hold several whitespace-separated tokens. Every token then
|
|
425
|
+
* has to land somewhere in the name, and the result takes the weakest of
|
|
426
|
+
* them — so `mod inte` finds `ModelScope International`, while `mod zzz`
|
|
427
|
+
* still finds nothing. A single token keeps the original ranking exactly,
|
|
428
|
+
* so existing ordering does not shift.
|
|
429
|
+
*/
|
|
430
|
+
function matchScore(name, query) {
|
|
431
|
+
const tokens = searchTokens(query)
|
|
432
|
+
if (tokens.length === 0) return 0
|
|
433
|
+
const n = String(name ?? '').toLowerCase()
|
|
434
|
+
if (tokens.length === 1) return tokenScore(n, tokens[0])
|
|
435
|
+
let weakest = 0
|
|
436
|
+
for (const token of tokens) {
|
|
437
|
+
const score = tokenScore(n, token)
|
|
438
|
+
if (score === null) return null
|
|
439
|
+
if (score > weakest) weakest = score
|
|
440
|
+
}
|
|
441
|
+
return weakest
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Rank of a model that is shown because its PROVIDER matched the query
|
|
446
|
+
* rather than its own name. Sorts after every name hit; the sort is stable,
|
|
447
|
+
* so models pulled in this way keep their catalog order.
|
|
448
|
+
*/
|
|
449
|
+
const PROVIDER_MATCH = 3
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------
|
|
452
|
+
// Insufficient balance / credit / quota — the per-model strike state.
|
|
453
|
+
//
|
|
454
|
+
// The Host already classifies an exhausted account (rather than a
|
|
455
|
+
// transient rate limit) into the provider-neutral `QUOTA` and
|
|
456
|
+
// `ACCOUNT_QUOTA` failure codes, and a provider HTTP 402 maps to `QUOTA`
|
|
457
|
+
// (dsh-llm/src/error.ts `isQuotaExceededError`, dsh-llm-deepseek
|
|
458
|
+
// `providerError`). Those facts reach the Client on the durable
|
|
459
|
+
// `turn/end` event as `reason.error` = `{ code, message, status? }`, so the
|
|
460
|
+
// panel does not need a Host change: it folds the session event feed.
|
|
461
|
+
//
|
|
462
|
+
// The wording pass exists because a failure can be flattened to a
|
|
463
|
+
// transient-looking code with the provider's text kept in `message`. The
|
|
464
|
+
// provider-status code is NOT authoritative for the account state: several
|
|
465
|
+
// providers answer an exhausted balance with **HTTP 429** rather than 402 —
|
|
466
|
+
// e.g. `429: {"message":"insufficient balance","request_id":"…"}` — and the
|
|
467
|
+
// Host classifies that as `RATE_LIMIT`, which is normally transient. The
|
|
468
|
+
// message therefore has to decide in that case, so a 429 with balance
|
|
469
|
+
// wording strikes while a 429 that is a real rate limit does not.
|
|
470
|
+
//
|
|
471
|
+
// The wording pass is deliberately narrow: a bare "402"/"429" only counts
|
|
472
|
+
// when it reads as the leading status, never as a number inside another
|
|
473
|
+
// message, and every example below is taken from a real recorded session.
|
|
474
|
+
// ---------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/** Failure codes that mean "this account has no usable balance left". */
|
|
477
|
+
const INSUFFICIENT_CODES = new Set([
|
|
478
|
+
'QUOTA', 'ACCOUNT_QUOTA', 'INSUFFICIENT_QUOTA', 'INSUFFICIENT_BALANCE', 'BUDGET_EXHAUSTED',
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Provider wording for an exhausted balance, credit, or account quota.
|
|
483
|
+
* Mirrors the Host's own classifier so the panel and the Chat's quota notice
|
|
484
|
+
* agree about what counts, plus the provider-specific shapes seen in real
|
|
485
|
+
* sessions (the Host's regexes are not importable from a browser bundle).
|
|
486
|
+
*/
|
|
487
|
+
const INSUFFICIENT_TEXT = [
|
|
488
|
+
// "insufficient balance" / "insufficient_balance_error" / "insufficient credits".
|
|
489
|
+
/\binsufficient[\s_-]+(?:quota|balance|credits?|funds?|user[\s_-]+quota)\b/i,
|
|
490
|
+
// "quota exceeded" / "usage limit reached" / "quota_exceeded".
|
|
491
|
+
/\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i,
|
|
492
|
+
// "exceeded your current quota".
|
|
493
|
+
/\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i,
|
|
494
|
+
// "balance exhausted" / "credits depleted" / "credit too low".
|
|
495
|
+
/\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted|too[\s_-]+low)\b/i,
|
|
496
|
+
// "out of credits" / "out of budget".
|
|
497
|
+
/\bout[\s_-]+of[\s_-]+(?:credits?|budget|balance)\b/i,
|
|
498
|
+
// "no credits remaining" (OpenAI) / "no available balance".
|
|
499
|
+
/\bno[\s_-]+(?:available[\s_-]+)?(?:credits?|balance|quota)\b/i,
|
|
500
|
+
/\bno[\s_-]+credits?[\s_-]+remaining\b/i,
|
|
501
|
+
// "Not enough quota to pre-consume" (new-api gateways).
|
|
502
|
+
/\bnot[\s_-]+enough[\s_-]+quota\b/i,
|
|
503
|
+
// "doesn't have any credits or licenses yet" (xAI).
|
|
504
|
+
/\b(?:any|no)[\s_-]+credits?[\s_-]+or[\s_-]+licen[cs]es?\b/i,
|
|
505
|
+
// "Billing daily count exceeded" / billing hard limit.
|
|
506
|
+
/\bbilling[\s_-]+(?:daily[\s_-]+count[\s_-]+exceeded|hard[\s_-]+limit)\b/i,
|
|
507
|
+
// "Your requests have exceeded the quota" / "quota is exhausted" (Trae).
|
|
508
|
+
/\bquota[\s_-]+is[\s_-]+exhausted\b/i,
|
|
509
|
+
/\brequests?[\s_-]+have[\s_-]+exceeded[\s_-]+the[\s_-]+quota\b/i,
|
|
510
|
+
/\bpayment[\s_-]+required\b/i,
|
|
511
|
+
// Chinese provider wording: 额度已用尽 / 余额不足 / 预算已用完 / 欠费.
|
|
512
|
+
/额度不足|余额不足|额度已用尽|余额已用尽|欠费|账户余额|额度已耗尽/,
|
|
513
|
+
/(?:预算|额度)[^。;;]{0,12}(?:已用完|已用尽|不足|耗尽)/,
|
|
514
|
+
]
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Decide whether one recorded failure means the account ran out of
|
|
518
|
+
* balance rather than hitting a transient limit.
|
|
519
|
+
*
|
|
520
|
+
* A structured balance code is authoritative. A provider status of `402`
|
|
521
|
+
* ("payment required") is too. **`429` is NOT authoritative either way:**
|
|
522
|
+
* some providers report an exhausted balance with 429 and a body that says
|
|
523
|
+
* so (`429: {"message":"insufficient balance",…}`), which the Host
|
|
524
|
+
* classifies as `RATE_LIMIT`. For every other case the message decides, so
|
|
525
|
+
* a 429 carrying balance wording strikes while a genuine rate limit does
|
|
526
|
+
* not.
|
|
527
|
+
* @param {unknown} error - a `turn/end` `reason.error` (`{ code, message, status }`), or a thrown value.
|
|
528
|
+
* @returns {boolean} true only for an exhausted balance, credit, or quota.
|
|
529
|
+
*/
|
|
530
|
+
function isInsufficientBalance(error) {
|
|
531
|
+
if (error === null || typeof error !== 'object') {
|
|
532
|
+
return typeof error === 'string' ? INSUFFICIENT_TEXT.some(pattern => pattern.test(error)) : false
|
|
533
|
+
}
|
|
534
|
+
const code = typeof error.code === 'string' ? error.code.toUpperCase() : ''
|
|
535
|
+
if (INSUFFICIENT_CODES.has(code)) return true
|
|
536
|
+
// A provider status of 402 means "payment required", which in practice is
|
|
537
|
+
// the account running dry. The structured `status` field is preferred; a
|
|
538
|
+
// code shaped `HTTP_402` carries the same fact.
|
|
539
|
+
if (error.status === 402 || code === 'HTTP_402') return true
|
|
540
|
+
// A 429 (or any other status) is decided by the text: providers use 429
|
|
541
|
+
// for BOTH a transient rate limit and an exhausted balance.
|
|
542
|
+
const message = typeof error.message === 'string' ? error.message : ''
|
|
543
|
+
if (INSUFFICIENT_TEXT.some(pattern => pattern.test(message))) return true
|
|
544
|
+
// `insufficient_user_quota` and friends can arrive only as a structured
|
|
545
|
+
// code inside the message body rather than as the failure code.
|
|
546
|
+
return /\binsufficient[\s_-]+user[\s_-]+quota\b/i.test(message)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Stable identity of one model route. The strike state is keyed by route,
|
|
551
|
+
* not by row, because the same model appears under both grouping modes and
|
|
552
|
+
* a provider may publish the same id under two groups.
|
|
553
|
+
* @param {unknown} provider - provider id.
|
|
554
|
+
* @param {unknown} model - provider-owned model id.
|
|
555
|
+
* @returns {string} the composite key.
|
|
556
|
+
*/
|
|
557
|
+
function routeKeyOf(provider, model) {
|
|
558
|
+
return `${String(provider ?? '')}/${String(model ?? '')}`
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Turn endings that PROVE the model route answered, so a standing
|
|
563
|
+
* insufficient-balance mark must end. A cancellation (`aborted`) or a turn
|
|
564
|
+
* closed before any request ran (`blocked`, and the synthetic
|
|
565
|
+
* `interrupted` / `forked` closers) proves nothing about the account — the
|
|
566
|
+
* model never answered — so those leave the mark alone.
|
|
567
|
+
*/
|
|
568
|
+
const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Fold ONE durable session event into the per-route insufficient-balance
|
|
572
|
+
* state. Pure and reference-preserving, so an unrelated event costs nothing
|
|
573
|
+
* and the caller can compare identity to decide whether to republish.
|
|
574
|
+
*
|
|
575
|
+
* A `turn/end` carrying a balance failure marks the route that request used.
|
|
576
|
+
* A later `turn/end` for the same route that proves the model answered
|
|
577
|
+
* without a balance failure clears the mark — a completed turn, or a turn
|
|
578
|
+
* that failed for another reason. A cancellation or a blocked turn clears
|
|
579
|
+
* nothing, because the model never answered. The route a `turn/end` belongs
|
|
580
|
+
* to is the latest `request/header` seen before it, which is exactly the
|
|
581
|
+
* model the failing request was dispatched to.
|
|
582
|
+
* @param {Map<string, boolean>} state - current marks, mutated in place.
|
|
583
|
+
* @param {unknown} event - one durable Session event (`{ type, data }`).
|
|
584
|
+
* @param {{ provider: string, model: string } | null} route - route resolved from the request header, or null.
|
|
585
|
+
* @returns {boolean} whether the state changed.
|
|
586
|
+
*/
|
|
587
|
+
function foldInsufficient(state, event, route) {
|
|
588
|
+
if (event === null || typeof event !== 'object') return false
|
|
589
|
+
if (event.type !== 'turn/end' || route === null) return false
|
|
590
|
+
const reason = event.data?.reason
|
|
591
|
+
if (reason === null || typeof reason !== 'object') return false
|
|
592
|
+
const key = routeKeyOf(route.provider, route.model)
|
|
593
|
+
if (reason.kind === 'error' && isInsufficientBalance(reason.error)) {
|
|
594
|
+
if (state.get(key) === true) return false
|
|
595
|
+
state.set(key, true)
|
|
596
|
+
return true
|
|
597
|
+
}
|
|
598
|
+
if (!CLEARING_TURN_ENDS.has(reason.kind)) return false
|
|
599
|
+
if (state.get(key) !== true) return false
|
|
600
|
+
state.delete(key)
|
|
601
|
+
return true
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Resolve the model route a request header names. The header's `config` is
|
|
606
|
+
* an `LlmCallConfig` (`{ provider, model, ... }`).
|
|
607
|
+
* @param {unknown} event - one durable Session event.
|
|
608
|
+
* @returns {{ provider: string, model: string } | null} the route, or null when this event is not a usable header.
|
|
609
|
+
*/
|
|
610
|
+
function routeOfHeader(event) {
|
|
611
|
+
if (event === null || typeof event !== 'object' || event.type !== 'request/header') return null
|
|
612
|
+
const config = event.data?.header?.config
|
|
613
|
+
if (config === null || typeof config !== 'object') return null
|
|
614
|
+
if (typeof config.provider !== 'string' || typeof config.model !== 'string') return null
|
|
615
|
+
return { provider: config.provider, model: config.model }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Every searchable face of a provider-authored text: a plain string is one
|
|
620
|
+
* face, a localized object contributes each language it carries. Provider
|
|
621
|
+
* names arrive localized (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the
|
|
622
|
+
* search has to see all of them — matching only the English face would make
|
|
623
|
+
* the Chinese name unsearchable.
|
|
624
|
+
* @param {unknown} value - a string, or a localized record of strings.
|
|
625
|
+
* @returns {string[]} lowercased, non-empty faces.
|
|
626
|
+
*/
|
|
627
|
+
function textFaces(value) {
|
|
628
|
+
if (typeof value === 'string') return [value.toLowerCase()].filter(text => text !== '')
|
|
629
|
+
if (value === null || typeof value !== 'object') return []
|
|
630
|
+
const faces = []
|
|
631
|
+
for (const face of Object.values(value)) {
|
|
632
|
+
if (typeof face !== 'string') continue
|
|
633
|
+
const text = face.toLowerCase()
|
|
634
|
+
if (text !== '') faces.push(text)
|
|
635
|
+
}
|
|
636
|
+
return faces
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Match the query against the provider itself — its id, its catalog name,
|
|
641
|
+
* and the label its section draws. A hit means "this vendor was asked for",
|
|
642
|
+
* so the whole catalog of that provider is listed: typing `dou` finds the
|
|
643
|
+
* `豆包(doubao)` provider and every model under it, including the ones whose
|
|
644
|
+
* own names never contain the query.
|
|
645
|
+
*
|
|
646
|
+
* A multi-token query matches the provider when every token lands in SOME
|
|
647
|
+
* face of the same provider, so `mod inte` finds a provider named
|
|
648
|
+
* `ModelScope International` even though those words are never contiguous.
|
|
649
|
+
* Tokens may land in different faces — the id answers `mod`, the localized
|
|
650
|
+
* name answers `inte`.
|
|
651
|
+
*
|
|
652
|
+
* Only prefix and substring are considered per token. A subsequence pass —
|
|
653
|
+
* which `matchScore` allows for model names — would let a two-letter query
|
|
654
|
+
* light up every provider in the directory, which is noise rather than a
|
|
655
|
+
* match.
|
|
656
|
+
* @param {{id?: string, name?: unknown}} group - one provider entry.
|
|
657
|
+
* @param {string} query - raw search text.
|
|
658
|
+
* @param {unknown} [title] - the label this provider's section draws.
|
|
659
|
+
* @returns {0|1|null} 0 for a prefix hit, 1 for a substring hit, else null.
|
|
660
|
+
*/
|
|
661
|
+
function providerScore(group, query, title) {
|
|
662
|
+
const tokens = searchTokens(query)
|
|
663
|
+
if (tokens.length === 0) return null
|
|
664
|
+
const texts = [...textFaces(group?.id), ...textFaces(group?.name), ...textFaces(title)]
|
|
665
|
+
if (texts.length === 0) return null
|
|
666
|
+
/** The best rank one token reaches across every face. */
|
|
667
|
+
const rankOf = token => {
|
|
668
|
+
let best = null
|
|
669
|
+
for (const text of texts) {
|
|
670
|
+
const rank = text.startsWith(token) ? 0 : text.includes(token) ? 1 : null
|
|
671
|
+
if (rank === null) continue
|
|
672
|
+
if (best === null || rank < best) best = rank
|
|
673
|
+
if (best === 0) return 0
|
|
674
|
+
}
|
|
675
|
+
return best
|
|
676
|
+
}
|
|
677
|
+
let weakest = 0
|
|
678
|
+
for (const token of tokens) {
|
|
679
|
+
const rank = rankOf(token)
|
|
680
|
+
if (rank === null) return null
|
|
681
|
+
if (rank > weakest) weakest = rank
|
|
682
|
+
}
|
|
683
|
+
return weakest
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ---------------------------------------------------------------------
|
|
687
|
+
// Model families: hard-coded dictionary → id-derived guess → Others.
|
|
688
|
+
// Families stay flat; Others always sorts last. A family is a BRAND, and
|
|
689
|
+
// a *registered* brand keeps its GENERATIONS apart: `hy3` is `HY3`,
|
|
690
|
+
// `hy4-preview`/`hy4free`/`hy4std` are `HY4`, while a bare `hy` is `HY`.
|
|
691
|
+
// Unregistered brands still collapse their version tail (`acme4-turbo`
|
|
692
|
+
// and `acme-7b` are one `Acme`). Dictionary families never split.
|
|
693
|
+
// ---------------------------------------------------------------------
|
|
694
|
+
const FAMILY_DICTIONARY = [
|
|
695
|
+
['DeepSeek', ['deepseek']],
|
|
696
|
+
['Grok', ['grok']],
|
|
697
|
+
['Opus', ['opus']],
|
|
698
|
+
['Haiku', ['haiku']],
|
|
699
|
+
['Sonnet', ['sonnet']],
|
|
700
|
+
['Claude', ['claude']],
|
|
701
|
+
['Qwen', ['qwen', 'qwq']],
|
|
702
|
+
['GLM', ['glm', 'chatglm']],
|
|
703
|
+
['Kimi', ['kimi', 'moonshot']],
|
|
704
|
+
['GPT', ['gpt', 'chatgpt', 'o1-', 'o3-', 'o4-']],
|
|
705
|
+
['Gemini', ['gemini', 'gemma']],
|
|
706
|
+
['Llama', ['llama']],
|
|
707
|
+
['Mistral', ['mistral', 'mixtral', 'codestral']],
|
|
708
|
+
['Doubao', ['doubao', 'seed-']],
|
|
709
|
+
['MiniMax', ['minimax', 'abab']],
|
|
710
|
+
['Hunyuan', ['hunyuan']],
|
|
711
|
+
['ERNIE', ['ernie']],
|
|
712
|
+
['Yi', ['yi-', '01-ai']],
|
|
713
|
+
['Phi', ['phi-']],
|
|
714
|
+
['Command', ['command-r']],
|
|
715
|
+
['Nova', ['nova-']],
|
|
716
|
+
['Step', ['step-']],
|
|
717
|
+
]
|
|
718
|
+
const OTHERS = 'Others'
|
|
719
|
+
const FAMILY_ALIASES = new Map([
|
|
720
|
+
['gpt', 'GPT'], ['glm', 'GLM'], ['qwen', 'Qwen'], ['kimi', 'Kimi'],
|
|
721
|
+
])
|
|
722
|
+
const VERSION_ONLY = /^(v|ver|r)?\d+([.-]\d+)*$/i
|
|
723
|
+
/**
|
|
724
|
+
* A brand token plus its version tail: `hy3`, `hy4-preview`, `hy4free`,
|
|
725
|
+
* `qwen2.5`. Group 1 is the brand, group 2 the leading generation digit(s)
|
|
726
|
+
* (captured so registered brands can keep generations apart), group 3 the
|
|
727
|
+
* rest of the variant tail.
|
|
728
|
+
*/
|
|
729
|
+
const BRAND_WITH_VERSION = /^([a-z][a-z]*?)(\d+)([\w.-]*)$/i
|
|
730
|
+
/**
|
|
731
|
+
* Registered brands, mapped to their exact heading casing. Sort with the
|
|
732
|
+
* dictionary families instead of after them. `HY` is the case that
|
|
733
|
+
* prompted this: its ids (`hy3`, `hy4-preview`, `hy4free`) share no name
|
|
734
|
+
* needle, and the brand is an acronym the generic title-casing would draw
|
|
735
|
+
* as `Hy`. A registered brand's family is `BRAND + generation` when the id
|
|
736
|
+
* carries a generation digit (so `hy3` → `HY3`, `hy4free` → `HY4`), and
|
|
737
|
+
* the bare brand otherwise (`hy` → `HY`). Dictionary families are never
|
|
738
|
+
* split by generation.
|
|
739
|
+
*/
|
|
740
|
+
const FAMILY_BRANDS = new Map([['hy', 'HY']])
|
|
741
|
+
const FAMILY_ORDER = [
|
|
742
|
+
...FAMILY_DICTIONARY.map(entry => entry[0]),
|
|
743
|
+
...FAMILY_BRANDS.values(),
|
|
744
|
+
]
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Split one versioned id token into its brand / generation / variant tail.
|
|
748
|
+
* @param {string} token - one lowercased id segment.
|
|
749
|
+
* @returns {{brand: string, generation: string, variant: string} | null}
|
|
750
|
+
*/
|
|
751
|
+
function versionParts(token) {
|
|
752
|
+
const hit = BRAND_WITH_VERSION.exec(token)
|
|
753
|
+
if (hit === null) return null
|
|
754
|
+
const brand = hit[1]
|
|
755
|
+
return brand.length < 2 ? null : { brand, generation: hit[2], variant: hit[3] }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The brand part of one versioned id token, or '' when the token carries
|
|
760
|
+
* no version tail.
|
|
761
|
+
* @param {string} token - one lowercased id segment.
|
|
762
|
+
* @returns {string} the lowercased brand, or ''.
|
|
763
|
+
*/
|
|
764
|
+
function brandOf(token) {
|
|
765
|
+
return versionParts(token)?.brand ?? ''
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Resolve one model's family.
|
|
770
|
+
* @param {{id: string, name: string}} model - catalog entry.
|
|
771
|
+
* @returns {string} a flat family name; never empty.
|
|
772
|
+
*/
|
|
773
|
+
function familyOf(model) {
|
|
774
|
+
const haystack = `${model.id} ${model.name}`.toLowerCase()
|
|
775
|
+
for (const entry of FAMILY_DICTIONARY) {
|
|
776
|
+
if (entry[1].some(needle => haystack.includes(needle))) return entry[0]
|
|
777
|
+
}
|
|
778
|
+
// Id-derived guess. The provider prefix is dropped, then every remaining
|
|
779
|
+
// segment is checked for a registered brand, so an id that prefixes the
|
|
780
|
+
// brand with its provider (`codebuddy-hy3`) still lands in `HY3`, and a
|
|
781
|
+
// bare brand id (`hy`) resolves to `HY` rather than the generic casing.
|
|
782
|
+
const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
|
|
783
|
+
if (segments.length === 0) return OTHERS
|
|
784
|
+
for (const segment of segments) {
|
|
785
|
+
const lower = segment.toLowerCase()
|
|
786
|
+
const parts = versionParts(lower)
|
|
787
|
+
const known = FAMILY_BRANDS.get(lower)
|
|
788
|
+
?? FAMILY_BRANDS.get(parts?.brand ?? '')
|
|
789
|
+
if (known !== undefined) {
|
|
790
|
+
// A registered brand keeps its generations distinct: hy3 → HY3,
|
|
791
|
+
// hy4free → HY4. A bare brand with no generation stays `HY`.
|
|
792
|
+
return parts === null ? known : `${known}${parts.generation}`
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const token = segments[0].toLowerCase()
|
|
796
|
+
if (token.length < 2 || VERSION_ONLY.test(token)) return OTHERS
|
|
797
|
+
const alias = FAMILY_ALIASES.get(token)
|
|
798
|
+
if (alias !== undefined) return alias
|
|
799
|
+
// An unregistered versioned segment collapses onto its brand, so
|
|
800
|
+
// `acme4-turbo` and `acme-7b` share one heading instead of two.
|
|
801
|
+
const brand = brandOf(token)
|
|
802
|
+
const stem = brand === '' ? token : brand
|
|
803
|
+
return stem.charAt(0).toUpperCase() + stem.slice(1)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Sort rank of a family. Registered-brand generations (`HY3`, `HY4`, …)
|
|
808
|
+
* rank with their bare brand (`HY`) and order by generation number, so a
|
|
809
|
+
* new generation lands next to its siblings instead of at the end.
|
|
810
|
+
*/
|
|
811
|
+
function familyRank(family) {
|
|
812
|
+
if (family === OTHERS) return Number.MAX_SAFE_INTEGER
|
|
813
|
+
const known = FAMILY_ORDER.indexOf(family)
|
|
814
|
+
if (known !== -1) return known
|
|
815
|
+
for (const [brand, heading] of FAMILY_BRANDS) {
|
|
816
|
+
if (!family.startsWith(heading) || family.length === heading.length) continue
|
|
817
|
+
const generation = family.slice(heading.length)
|
|
818
|
+
if (!/^\d+$/.test(generation)) continue
|
|
819
|
+
return FAMILY_ORDER.indexOf(heading) + Number(generation) / 1000
|
|
820
|
+
}
|
|
821
|
+
return FAMILY_ORDER.length
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* A short variant label taken from a model id, used only to tell several
|
|
826
|
+
* models of ONE provider inside ONE family apart. The family prefix is
|
|
827
|
+
* stripped from every id segment, so hy4free → "free", hy4std → "std",
|
|
828
|
+
* hy4-preview → "preview", hy4flash-exp1 → "flash exp1", hy3-x → "x",
|
|
829
|
+
* and a bare hy3 → "". Pure version numbers are dropped. Returns '' when
|
|
830
|
+
* the id carries nothing beyond the family heading itself.
|
|
831
|
+
* @param {{id: string}} model - catalog entry.
|
|
832
|
+
* @param {string} family - the resolved family heading.
|
|
833
|
+
* @returns {string} a short variant label, or ''.
|
|
834
|
+
*/
|
|
835
|
+
function idVariantOf(model, family) {
|
|
836
|
+
const familyLower = family.toLowerCase()
|
|
837
|
+
const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
|
|
838
|
+
const words = []
|
|
839
|
+
for (const raw of segments) {
|
|
840
|
+
const segment = raw.toLowerCase()
|
|
841
|
+
// Remove the family heading from inside a glued segment: hy4free →
|
|
842
|
+
// free, hy4flash → flash. A segment equal to the family drops to ''.
|
|
843
|
+
let word = segment.startsWith(familyLower) ? segment.slice(familyLower.length) : segment
|
|
844
|
+
word = word.replace(/^[-._]+/, '')
|
|
845
|
+
if (word === '' || /^\d+(\.\d+)?$/.test(word)) continue
|
|
846
|
+
words.push(...word.split(/[-._]+/).filter(Boolean))
|
|
847
|
+
}
|
|
848
|
+
return words.filter(word => !/^\d+$/.test(word)).join(' ')
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The distinguishing part of one model for a family-grouped row: the id's
|
|
853
|
+
* variant tail first, then any catalog-name words that are not just the
|
|
854
|
+
* family heading or a bare version number. Returns '' when the model adds
|
|
855
|
+
* nothing beyond what the family heading already says.
|
|
856
|
+
* @param {{id: string, name: string}} model - catalog entry.
|
|
857
|
+
* @param {string} family - the resolved family heading.
|
|
858
|
+
* @returns {string} a short, non-family supplement; possibly ''.
|
|
859
|
+
*/
|
|
860
|
+
function rowVariantOf(model, family) {
|
|
861
|
+
const fromId = idVariantOf(model, family)
|
|
862
|
+
if (fromId !== '') return fromId
|
|
863
|
+
const familyWords = new Set(family.toLowerCase().split(/[\s-]+/).filter(Boolean))
|
|
864
|
+
return String(model.name ?? '').split(/[\s._-]+/).filter(word => {
|
|
865
|
+
const lower = word.toLowerCase()
|
|
866
|
+
return word !== '' && !familyWords.has(lower) && !/^\d+(\.\d+)?$/.test(lower)
|
|
867
|
+
}).join(' ')
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ---------------------------------------------------------------------
|
|
871
|
+
// Column planning
|
|
872
|
+
// ---------------------------------------------------------------------
|
|
873
|
+
/** One indivisible placement unit: a group title with its first model, or a lone model row. */
|
|
874
|
+
function buildUnits(sections, rowH, headH) {
|
|
875
|
+
const units = []
|
|
876
|
+
for (const section of sections) {
|
|
877
|
+
if (section.models.length === 0) continue
|
|
878
|
+
units.push({
|
|
879
|
+
height: headH + ROW_GAP + rowH,
|
|
880
|
+
blocks: [
|
|
881
|
+
{ kind: 'title', key: `t:${section.key}`, label: section.title },
|
|
882
|
+
{ kind: 'row', key: `r:${section.key}:0`, entry: section.models[0] },
|
|
883
|
+
],
|
|
884
|
+
})
|
|
885
|
+
for (let index = 1; index < section.models.length; index += 1) {
|
|
886
|
+
units.push({
|
|
887
|
+
height: rowH,
|
|
888
|
+
blocks: [{ kind: 'row', key: `r:${section.key}:${index}`, entry: section.models[index] }],
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return units
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function columnHeight(units) {
|
|
896
|
+
if (units.length === 0) return 0
|
|
897
|
+
return units.reduce((sum, unit) => sum + unit.height, 0) + ROW_GAP * (units.length - 1)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Flow units into `count` columns, then migrate the last column's deficit
|
|
902
|
+
* back into its neighbour. A group title never separates from its first
|
|
903
|
+
* model because both live in one unit.
|
|
904
|
+
*/
|
|
905
|
+
function packColumns(units, count) {
|
|
906
|
+
const columns = Array.from({ length: count }, () => [])
|
|
907
|
+
const target = columnHeight(units) / count
|
|
908
|
+
let index = 0
|
|
909
|
+
for (let column = 0; column < count && index < units.length; column += 1) {
|
|
910
|
+
const isLast = column === count - 1
|
|
911
|
+
let used = 0
|
|
912
|
+
while (index < units.length) {
|
|
913
|
+
const unit = units[index]
|
|
914
|
+
const add = used === 0 ? unit.height : unit.height + ROW_GAP
|
|
915
|
+
if (!isLast && used > 0 && used + add > target) break
|
|
916
|
+
columns[column].push(unit)
|
|
917
|
+
used += add
|
|
918
|
+
index += 1
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
while (index < units.length) {
|
|
922
|
+
columns[count - 1].push(units[index])
|
|
923
|
+
index += 1
|
|
924
|
+
}
|
|
925
|
+
// Balance the final pair only when it actually narrows the spread.
|
|
926
|
+
if (count > 1) {
|
|
927
|
+
for (let guard = 0; guard < units.length; guard += 1) {
|
|
928
|
+
const left = columns[count - 2]
|
|
929
|
+
const right = columns[count - 1]
|
|
930
|
+
if (left.length <= 1) break
|
|
931
|
+
const leftHeight = columnHeight(left)
|
|
932
|
+
const rightHeight = columnHeight(right)
|
|
933
|
+
if (leftHeight - rightHeight < BALANCE_THRESHOLD) break
|
|
934
|
+
const unit = left[left.length - 1]
|
|
935
|
+
const before = Math.max(leftHeight, rightHeight)
|
|
936
|
+
const after = Math.max(columnHeight(left.slice(0, -1)), columnHeight([...right, unit]))
|
|
937
|
+
if (after >= before) break
|
|
938
|
+
left.pop()
|
|
939
|
+
right.push(unit)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return columns
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Smallest column count (1–3) whose tallest column fits; otherwise 3 + scroll. */
|
|
946
|
+
function planColumns(units, available) {
|
|
947
|
+
if (units.length === 0) return { count: 1, columns: [[]] }
|
|
948
|
+
for (let count = 1; count <= 3; count += 1) {
|
|
949
|
+
const columns = packColumns(units, count)
|
|
950
|
+
if (Math.max(...columns.map(columnHeight)) <= available) return { count, columns }
|
|
951
|
+
}
|
|
952
|
+
return { count: 3, columns: packColumns(units, 3) }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ---------------------------------------------------------------------
|
|
956
|
+
// Dictionaries (this namespace owns the panel's own copy; the teamwork
|
|
957
|
+
// row prefers the host's `agent-team` wording when it is registered).
|
|
958
|
+
//
|
|
959
|
+
// The namespace id is minted per module generation. The locale registry
|
|
960
|
+
// keeps a namespace's slot after its disposer runs, and a re-registration
|
|
961
|
+
// of the same id throws — which would cost the seat its copy on the
|
|
962
|
+
// enable/disable/enable path. A fresh id makes every generation's
|
|
963
|
+
// registration independent, and the previous one is dropped with its own
|
|
964
|
+
// disposer.
|
|
965
|
+
// ---------------------------------------------------------------------
|
|
966
|
+
const NS = `modelFlatmap-${Math.random().toString(36).slice(2, 10)}`
|
|
967
|
+
|
|
968
|
+
// ---------------------------------------------------------------------
|
|
969
|
+
// Grouping persistence — across sessions and workgroups.
|
|
970
|
+
//
|
|
971
|
+
// The `按 Provider / 按模型家族` choice is a user preference that must
|
|
972
|
+
// survive a reload, a new conversation, and a workgroup switch. On DSH
|
|
973
|
+
// Desktop the Web GUI runs on a stable origin (a fixed bundled origin in
|
|
974
|
+
// production, `http://127.0.0.1:56668` in development), so `localStorage`
|
|
975
|
+
// — origin-scoped, not session-scoped — is exactly the right store: it
|
|
976
|
+
// persists across every one of those boundaries. DSH's own client store
|
|
977
|
+
// (`@deepseek-ai/dsh-client-store`) is the same `localStorage` wrapper
|
|
978
|
+
// under the hood, but importing an internal Harness client package is
|
|
979
|
+
// forbidden by the UI rules (it changes without notice and a throw blanks
|
|
980
|
+
// the seat). We therefore use `localStorage` directly — the public,
|
|
981
|
+
// self-contained mechanism the host endorses underneath.
|
|
982
|
+
//
|
|
983
|
+
// The store is read and written lazily, and every access degrades: when
|
|
984
|
+
// `localStorage` is unavailable (private mode, sandboxed frame) the choice
|
|
985
|
+
// still works for the lifetime of the tab, falling back to the in-memory
|
|
986
|
+
// default rather than throwing.
|
|
987
|
+
// ---------------------------------------------------------------------
|
|
988
|
+
const GROUPING_KEY = 'dshmf:grouping'
|
|
989
|
+
const GROUPING_DEFAULT = 'provider'
|
|
990
|
+
/**
|
|
991
|
+
* Read the persisted grouping, or the default when nothing valid was stored.
|
|
992
|
+
* @returns `'provider'` or `'family'`.
|
|
993
|
+
*/
|
|
994
|
+
function readGrouping() {
|
|
995
|
+
try {
|
|
996
|
+
if (typeof localStorage === 'undefined') return GROUPING_DEFAULT
|
|
997
|
+
const value = localStorage.getItem(GROUPING_KEY)
|
|
998
|
+
return value === 'provider' || value === 'family' ? value : GROUPING_DEFAULT
|
|
999
|
+
} catch {
|
|
1000
|
+
return GROUPING_DEFAULT
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Persist one grouping choice. A write failure (quota, disabled storage)
|
|
1005
|
+
* is swallowed: the in-memory state still drives the current tab.
|
|
1006
|
+
* @param {'provider'|'family'} value - the choice to persist.
|
|
1007
|
+
*/
|
|
1008
|
+
function writeGrouping(value) {
|
|
1009
|
+
try {
|
|
1010
|
+
if (typeof localStorage === 'undefined') return
|
|
1011
|
+
localStorage.setItem(GROUPING_KEY, value)
|
|
1012
|
+
} catch {
|
|
1013
|
+
/* storage unavailable — keep the in-memory choice only */
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const dict = {
|
|
1018
|
+
zh: {
|
|
1019
|
+
'provider.account': 'DeepSeek 账号',
|
|
1020
|
+
'search.placeholder': '搜索模型…',
|
|
1021
|
+
'search.clear': '清除搜索',
|
|
1022
|
+
'group.provider': '按 Provider',
|
|
1023
|
+
'group.family': '按模型家族',
|
|
1024
|
+
'group.aria': '模型分组方式',
|
|
1025
|
+
'empty.models': '暂无可用模型',
|
|
1026
|
+
'empty.search': '无匹配模型',
|
|
1027
|
+
'status.loading': '正在刷新模型列表…',
|
|
1028
|
+
'trigger.fallback': '请选择模型',
|
|
1029
|
+
'trigger.loading': '正在加载模型…',
|
|
1030
|
+
'trigger.aria': '选择模型,当前 {model}',
|
|
1031
|
+
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
|
1032
|
+
'panel.aria': '模型与推理等级',
|
|
1033
|
+
'effort.default': 'Default',
|
|
1034
|
+
'effort.label': '推理等级',
|
|
1035
|
+
'effort.hint': '调整模型的思考强度:档位越高,思考越深入,但速度更慢、token 开销更大。',
|
|
1036
|
+
'team.label': '启用智能体团队协作',
|
|
1037
|
+
'team.description': '通过共享任务板协调多个智能体的分工与进度,复杂任务表现更佳,token 开销也相应更高。',
|
|
1038
|
+
'team.failed': '无法确认智能体团队协作设置已保存,请重试:{message}',
|
|
1039
|
+
'team.disable.title': '确认停用智能体团队协作?',
|
|
1040
|
+
'team.disable.description': '对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。',
|
|
1041
|
+
'team.disable.warning': '停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。',
|
|
1042
|
+
'team.disable.cancel': '取消',
|
|
1043
|
+
'team.disable.confirm': '确认停用',
|
|
1044
|
+
'failure.group': '{name} 加载失败:{message}',
|
|
1045
|
+
'error.action': '模型操作失败:{message}',
|
|
1046
|
+
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他正在运行的 DSH 后重试。',
|
|
1047
|
+
},
|
|
1048
|
+
en: {
|
|
1049
|
+
'provider.account': 'DeepSeek Account',
|
|
1050
|
+
'search.placeholder': 'Search models…',
|
|
1051
|
+
'search.clear': 'Clear search',
|
|
1052
|
+
'group.provider': 'By provider',
|
|
1053
|
+
'group.family': 'By family',
|
|
1054
|
+
'group.aria': 'Group models by',
|
|
1055
|
+
'empty.models': 'No models available',
|
|
1056
|
+
'empty.search': 'No matching models',
|
|
1057
|
+
'status.loading': 'Refreshing model list…',
|
|
1058
|
+
'trigger.fallback': 'Select model',
|
|
1059
|
+
'trigger.loading': 'Loading models…',
|
|
1060
|
+
'trigger.aria': 'Select model, current {model}',
|
|
1061
|
+
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
|
1062
|
+
'panel.aria': 'Model and reasoning effort',
|
|
1063
|
+
'effort.default': 'Default',
|
|
1064
|
+
'effort.label': 'Reasoning effort',
|
|
1065
|
+
'effort.hint': 'Adjusts how deeply the model thinks: higher levels reason more deeply, but are slower and cost more tokens.',
|
|
1066
|
+
'team.label': 'Enable agent teamwork',
|
|
1067
|
+
'team.description': 'Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs.',
|
|
1068
|
+
'team.failed': 'Could not confirm the agent teamwork setting was saved. Try again: {message}',
|
|
1069
|
+
'team.disable.title': 'Disable agent teamwork?',
|
|
1070
|
+
'team.disable.description': 'Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions.',
|
|
1071
|
+
'team.disable.warning': 'Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task.',
|
|
1072
|
+
'team.disable.cancel': 'Cancel',
|
|
1073
|
+
'team.disable.confirm': 'Disable',
|
|
1074
|
+
'failure.group': '{name} failed to load: {message}',
|
|
1075
|
+
'error.action': 'Model operation failed: {message}',
|
|
1076
|
+
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
|
|
1077
|
+
},
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const EMPTY_STATE = {
|
|
1081
|
+
current: null,
|
|
1082
|
+
retainedEffort: undefined,
|
|
1083
|
+
routable: null,
|
|
1084
|
+
groups: [],
|
|
1085
|
+
failures: [],
|
|
1086
|
+
status: 'idle',
|
|
1087
|
+
pending: null,
|
|
1088
|
+
error: null,
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Stand-in store used before a Session's directory resolves. It never
|
|
1093
|
+
* notifies, so `useSyncExternalStore` keeps a constant hook shape while the
|
|
1094
|
+
* real store is swapped in — the component therefore always renders a
|
|
1095
|
+
* trigger, never `null`.
|
|
1096
|
+
*/
|
|
1097
|
+
const EMPTY_STORE = {
|
|
1098
|
+
subscribe: () => () => {},
|
|
1099
|
+
getSnapshot: () => EMPTY_STATE,
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
|
|
1104
|
+
* stable for every seat that has no Session feed, so an unmarked panel never
|
|
1105
|
+
* re-renders because of this feature.
|
|
1106
|
+
*/
|
|
1107
|
+
const EMPTY_INSUFFICIENT_MARKS = new Map()
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* The one empty snapshot object. It MUST be a single shared instance:
|
|
1111
|
+
* `useSyncExternalStore` compares snapshots by identity, so returning a
|
|
1112
|
+
* fresh `{ marks }` on every call would look like a change on every render
|
|
1113
|
+
* and loop.
|
|
1114
|
+
*/
|
|
1115
|
+
const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Stand-in mark face used before a Session's feed resolves (or when the face
|
|
1119
|
+
* degraded). It never notifies and always answers the same empty snapshot.
|
|
1120
|
+
*/
|
|
1121
|
+
const EMPTY_INSUFFICIENT = {
|
|
1122
|
+
subscribe: () => () => {},
|
|
1123
|
+
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1124
|
+
dispose: () => {},
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ---------------------------------------------------------------------
|
|
1128
|
+
// Component
|
|
1129
|
+
// ---------------------------------------------------------------------
|
|
1130
|
+
/**
|
|
1131
|
+
* Render the flat model picker seat.
|
|
1132
|
+
* @param {object} props - slot standard props + owner `locked` + injected face.
|
|
1133
|
+
*/
|
|
1134
|
+
function ModelFlatMap(props) {
|
|
1135
|
+
const {
|
|
1136
|
+
locked, sessionId, t,
|
|
1137
|
+
available, directory, load, select, resolveText, resolveDirectory,
|
|
1138
|
+
teamAvailable, setTeamMode, useProjection, useSession, useSessions,
|
|
1139
|
+
insufficient,
|
|
1140
|
+
} = props
|
|
1141
|
+
|
|
1142
|
+
// The store may arrive after the first render (the Session binds later),
|
|
1143
|
+
// so resolve it on each render and fall back to a stable empty store.
|
|
1144
|
+
const resolved = directory === undefined && typeof resolveDirectory === 'function'
|
|
1145
|
+
? resolveDirectory()
|
|
1146
|
+
: undefined
|
|
1147
|
+
const store = directory ?? resolved?.store ?? EMPTY_STORE
|
|
1148
|
+
|
|
1149
|
+
const subscribe = useCallback(listener => store.subscribe(listener), [store])
|
|
1150
|
+
const getSnapshot = useCallback(() => store.getSnapshot(), [store])
|
|
1151
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
1152
|
+
|
|
1153
|
+
// Per-model insufficient-balance marks, folded from the Session's own
|
|
1154
|
+
// durable event feed by the face. The fallback keeps the hook shape stable
|
|
1155
|
+
// for a composition that omits the face.
|
|
1156
|
+
const markFace = insufficient ?? EMPTY_INSUFFICIENT
|
|
1157
|
+
const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
|
|
1158
|
+
const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
|
|
1159
|
+
const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
|
|
1160
|
+
|
|
1161
|
+
// The mark face observes the Session feed for as long as this seat is
|
|
1162
|
+
// mounted. Detaching on unmount cannot lose a mark: a remount refolds from
|
|
1163
|
+
// the full durable window, so the state is rebuilt rather than remembered.
|
|
1164
|
+
useEffect(() => () => { markFace.dispose() }, [markFace])
|
|
1165
|
+
|
|
1166
|
+
const [open, setOpen] = useState(false)
|
|
1167
|
+
const [query, setQuery] = useState('')
|
|
1168
|
+
// The grouping choice is restored from storage so it persists across
|
|
1169
|
+
// sessions and workgroups, not just within one panel mount.
|
|
1170
|
+
const [grouping, setGrouping] = useState(readGrouping())
|
|
1171
|
+
const [selectionFocus, setSelectionFocus] = useState(false)
|
|
1172
|
+
const [metrics, setMetrics] = useState({ rowH: 26, headH: 20, fixed: 0, fixedCount: 0, box: null })
|
|
1173
|
+
const [teamPending, setTeamPending] = useState(false)
|
|
1174
|
+
const [teamFailure, setTeamFailure] = useState(null)
|
|
1175
|
+
const [confirming, setConfirming] = useState(false)
|
|
1176
|
+
const [replan, setReplan] = useState(0)
|
|
1177
|
+
|
|
1178
|
+
const rootRef = useRef(null)
|
|
1179
|
+
const triggerRef = useRef(null)
|
|
1180
|
+
const panelRef = useRef(null)
|
|
1181
|
+
const searchRef = useRef(null)
|
|
1182
|
+
const probeRowRef = useRef(null)
|
|
1183
|
+
const probeHeadRef = useRef(null)
|
|
1184
|
+
const headRef = useRef(null)
|
|
1185
|
+
const errorRef = useRef(null)
|
|
1186
|
+
const reasonRef = useRef(null)
|
|
1187
|
+
const teamRef = useRef(null)
|
|
1188
|
+
const footerRef = useRef(null)
|
|
1189
|
+
const teamErrorRef = useRef(null)
|
|
1190
|
+
const confirmRef = useRef(null)
|
|
1191
|
+
const id = useId()
|
|
1192
|
+
|
|
1193
|
+
// Standard props always carry these three hooks for a session-scope slot;
|
|
1194
|
+
// the fallbacks keep the component renderable (rather than throwing) in a
|
|
1195
|
+
// composition that omits one, and they are plain values so the hook order
|
|
1196
|
+
// below never changes.
|
|
1197
|
+
const useProjectionSafe = typeof useProjection === 'function' ? useProjection : () => undefined
|
|
1198
|
+
const useSessionSafe = typeof useSession === 'function' ? useSession : () => null
|
|
1199
|
+
const useSessionsSafe = typeof useSessions === 'function' ? useSessions : () => undefined
|
|
1200
|
+
|
|
1201
|
+
const teamMode = useProjectionSafe('agentTeam')
|
|
1202
|
+
const modelSelection = useProjectionSafe('modelSelection')
|
|
1203
|
+
// Both hooks may hand back `undefined` before the Session binds, so the
|
|
1204
|
+
// selectors never assume an object.
|
|
1205
|
+
const addressedChild = useSessionSafe(
|
|
1206
|
+
snapshot => snapshot?.subagent != null,
|
|
1207
|
+
) === true
|
|
1208
|
+
const listedChild = useSessionsSafe(
|
|
1209
|
+
listener => listener?.byId?.[sessionId]?.origin === 'subagent',
|
|
1210
|
+
) === true
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Run a pure derivation and degrade instead of throwing.
|
|
1214
|
+
*
|
|
1215
|
+
* A render-time throw leaves the cell to the boundary, and a shadowing
|
|
1216
|
+
* entry that throws twice is worth avoiding: one malformed catalog field
|
|
1217
|
+
* must not cost the whole control. The first failure per label is logged.
|
|
1218
|
+
* @param label - which derivation failed, for the console.
|
|
1219
|
+
* @param run - the derivation.
|
|
1220
|
+
* @param fallback - the value to use when it throws.
|
|
1221
|
+
* @returns the derived value, or the fallback.
|
|
1222
|
+
*/
|
|
1223
|
+
const derive = (label, run, fallback) => guarded(label, run, fallback)
|
|
1224
|
+
|
|
1225
|
+
// Provider order matches the shipped picker: account, official, catalog order.
|
|
1226
|
+
const groups = useMemo(() => derive('grouping providers', () => {
|
|
1227
|
+
const ranked = state.groups.map((group, index) => ({ group, index }))
|
|
1228
|
+
const rankOf = value => (value.group.id === 'deepseek-account' ? 0
|
|
1229
|
+
: value.group.id === 'deepseek-official' ? 1 : 2)
|
|
1230
|
+
ranked.sort((left, right) => rankOf(left) - rankOf(right) || left.index - right.index)
|
|
1231
|
+
return ranked.map(entry => entry.group)
|
|
1232
|
+
}, []), [state.groups])
|
|
1233
|
+
|
|
1234
|
+
/** Provider or family sections, search-filtered; empty groups never appear. */
|
|
1235
|
+
const sections = useMemo(() => derive('grouping models', () => {
|
|
1236
|
+
const out = []
|
|
1237
|
+
/**
|
|
1238
|
+
* The provider's display label. Catalog names arrive localized
|
|
1239
|
+
* (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the label is resolved
|
|
1240
|
+
* once here and reused as the section title, the search face, and the
|
|
1241
|
+
* per-row provider tag. The account provider has no catalog name of
|
|
1242
|
+
* its own and borrows the localized "DeepSeek Account" wording.
|
|
1243
|
+
* @param {{id: string, name: unknown}} group - one provider entry.
|
|
1244
|
+
* @returns {string} a display-ready label; never empty.
|
|
1245
|
+
*/
|
|
1246
|
+
const providerLabelOf = group => {
|
|
1247
|
+
if (group.id === 'deepseek-account') return t('provider.account')
|
|
1248
|
+
const resolved = resolveText === undefined ? undefined : resolveText(group.name)
|
|
1249
|
+
if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
|
|
1250
|
+
const fallback = fallbackText(group.name)
|
|
1251
|
+
return fallback.trim() === '' ? group.id : fallback
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* A provider whose own name or id matches the query keeps its whole
|
|
1255
|
+
* catalog: `dou` shows the `豆包(doubao)` provider with every model it
|
|
1256
|
+
* offers. Models matched by their own name still rank first, so a
|
|
1257
|
+
* provider-wide hit only widens the result, never reorders it.
|
|
1258
|
+
*/
|
|
1259
|
+
const providerHitOf = group => providerScore(group, query, providerLabelOf(group))
|
|
1260
|
+
/** One model's rank, or null when neither it nor its provider matched. */
|
|
1261
|
+
const scoreOf = (model, providerHit) => {
|
|
1262
|
+
const byName = matchScore(model.name, query)
|
|
1263
|
+
if (byName !== null) return byName
|
|
1264
|
+
return providerHit ? PROVIDER_MATCH : null
|
|
1265
|
+
}
|
|
1266
|
+
if (grouping === 'provider') {
|
|
1267
|
+
for (const group of groups) {
|
|
1268
|
+
const providerHit = providerHitOf(group) !== null
|
|
1269
|
+
const providerLabel = providerLabelOf(group)
|
|
1270
|
+
const scored = []
|
|
1271
|
+
for (const model of group.models) {
|
|
1272
|
+
const score = scoreOf(model, providerHit)
|
|
1273
|
+
if (score !== null) {
|
|
1274
|
+
scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (scored.length === 0) continue
|
|
1278
|
+
scored.sort((left, right) => left.score - right.score)
|
|
1279
|
+
out.push({
|
|
1280
|
+
key: group.id,
|
|
1281
|
+
title: providerLabel,
|
|
1282
|
+
models: scored.map(item => item.entry),
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
return out
|
|
1286
|
+
}
|
|
1287
|
+
const buckets = new Map()
|
|
1288
|
+
for (const group of groups) {
|
|
1289
|
+
const providerHit = providerHitOf(group) !== null
|
|
1290
|
+
const providerLabel = providerLabelOf(group)
|
|
1291
|
+
for (const model of group.models) {
|
|
1292
|
+
const score = scoreOf(model, providerHit)
|
|
1293
|
+
if (score === null) continue
|
|
1294
|
+
const family = familyOf(model)
|
|
1295
|
+
let bucket = buckets.get(family)
|
|
1296
|
+
if (bucket === undefined) {
|
|
1297
|
+
bucket = { key: family, title: family, scored: [] }
|
|
1298
|
+
buckets.set(family, bucket)
|
|
1299
|
+
}
|
|
1300
|
+
bucket.scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const families = [...buckets.values()]
|
|
1304
|
+
families.sort((left, right) => familyRank(left.title) - familyRank(right.title)
|
|
1305
|
+
|| left.title.localeCompare(right.title))
|
|
1306
|
+
for (const family of families) {
|
|
1307
|
+
family.scored.sort((left, right) => left.score - right.score)
|
|
1308
|
+
// Under family grouping each row's primary text is its provider and
|
|
1309
|
+
// the family name is NOT repeated (the heading already says it). Only
|
|
1310
|
+
// when ONE provider offers several models in this family do the rows
|
|
1311
|
+
// need a short distinguishing tail, so the same provider name is not
|
|
1312
|
+
// printed twice with no way to tell the models apart.
|
|
1313
|
+
const byProvider = new Map()
|
|
1314
|
+
for (const item of family.scored) {
|
|
1315
|
+
const list = byProvider.get(item.entry.providerId) ?? []
|
|
1316
|
+
list.push(item)
|
|
1317
|
+
byProvider.set(item.entry.providerId, list)
|
|
1318
|
+
}
|
|
1319
|
+
for (const list of byProvider.values()) {
|
|
1320
|
+
if (list.length <= 1) continue
|
|
1321
|
+
const used = new Map()
|
|
1322
|
+
for (const item of list) {
|
|
1323
|
+
// An empty variant is intentional: the plain/base model of this
|
|
1324
|
+
// family carries no tag at all. Only a real collision (two rows
|
|
1325
|
+
// with the same non-empty variant, or two rows both empty) needs
|
|
1326
|
+
// a distinguishing fallback — the model id, which is unique.
|
|
1327
|
+
const variant = rowVariantOf(item.entry.model, family.title)
|
|
1328
|
+
item.entry.rowVariant = variant
|
|
1329
|
+
used.set(variant, (used.get(variant) ?? 0) + 1)
|
|
1330
|
+
}
|
|
1331
|
+
if ([...used.values()].some(count => count > 1)) {
|
|
1332
|
+
for (const item of list) {
|
|
1333
|
+
if (used.get(item.entry.rowVariant) > 1) {
|
|
1334
|
+
item.entry.rowVariant = String(item.entry.model.id ?? '')
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
out.push({ key: family.key, title: family.title, models: family.scored.map(item => item.entry) })
|
|
1340
|
+
}
|
|
1341
|
+
return out
|
|
1342
|
+
}, []), [grouping, groups, query, t, resolveText])
|
|
1343
|
+
|
|
1344
|
+
const totalModels = useMemo(
|
|
1345
|
+
() => groups.reduce((sum, group) => sum + group.models.length, 0),
|
|
1346
|
+
[groups],
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
const currentModel = useMemo(() => derive('resolving the current model', () => {
|
|
1350
|
+
if (state.current === null) return null
|
|
1351
|
+
for (const group of groups) {
|
|
1352
|
+
if (group.id !== state.current.provider) continue
|
|
1353
|
+
for (const model of group.models) {
|
|
1354
|
+
if (model.id === state.current.model) return { group, model }
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return null
|
|
1358
|
+
}, null), [groups, state.current])
|
|
1359
|
+
|
|
1360
|
+
const reasoning = currentModel === undefined || currentModel === null
|
|
1361
|
+
? undefined
|
|
1362
|
+
: currentModel.model.reasoning
|
|
1363
|
+
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
1364
|
+
const effortLabel = reasoning === undefined
|
|
1365
|
+
? state.retainedEffort
|
|
1366
|
+
: effectiveEffort === undefined
|
|
1367
|
+
? t('effort.default')
|
|
1368
|
+
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
1369
|
+
const effortChoices = reasoning === undefined ? [] : reasoning.efforts
|
|
1370
|
+
const busy = state.pending !== null
|
|
1371
|
+
|
|
1372
|
+
// ---- measurement: probe metrics + fixed-section heights
|
|
1373
|
+
// The reasoning group and the teamwork row now share ONE footer flex
|
|
1374
|
+
// container, so measure that container once instead of the two children
|
|
1375
|
+
// separately: when they sit on the same line the two heights overlap,
|
|
1376
|
+
// and summing them would over-subtract from the grid's budget.
|
|
1377
|
+
useLayoutEffect(() => {
|
|
1378
|
+
if (!open) return
|
|
1379
|
+
const rowH = probeRowRef.current?.getBoundingClientRect().height ?? 26
|
|
1380
|
+
const headH = probeHeadRef.current?.getBoundingClientRect().height ?? 20
|
|
1381
|
+
const heights = [headRef, errorRef, footerRef, confirmRef, teamErrorRef]
|
|
1382
|
+
.map(ref => ref.current?.offsetHeight ?? 0)
|
|
1383
|
+
.filter(height => height > 0)
|
|
1384
|
+
const fixed = heights.reduce((sum, height) => sum + height, 0)
|
|
1385
|
+
const fixedCount = heights.length
|
|
1386
|
+
setMetrics(previous => (previous.rowH === rowH && previous.headH === headH
|
|
1387
|
+
&& previous.fixed === fixed && previous.fixedCount === fixedCount
|
|
1388
|
+
? previous
|
|
1389
|
+
: { ...previous, rowH, headH, fixed, fixedCount }))
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
// ---- placement: above the trigger, kept inside the viewport
|
|
1393
|
+
useLayoutEffect(() => {
|
|
1394
|
+
if (!open) {
|
|
1395
|
+
setMetrics(previous => (previous.box === null ? previous : { ...previous, box: null }))
|
|
1396
|
+
return undefined
|
|
1397
|
+
}
|
|
1398
|
+
const place = () => {
|
|
1399
|
+
const trigger = triggerRef.current
|
|
1400
|
+
const panel = panelRef.current
|
|
1401
|
+
if (trigger === null || panel === null) return
|
|
1402
|
+
const rect = trigger.getBoundingClientRect()
|
|
1403
|
+
const vw = window.innerWidth
|
|
1404
|
+
const vh = window.innerHeight
|
|
1405
|
+
const width = Math.max(280, Math.min(560, vw - 24))
|
|
1406
|
+
const height = panel.offsetHeight
|
|
1407
|
+
const left = Math.max(12, Math.min(rect.right - width, vw - 12 - width))
|
|
1408
|
+
let top = rect.top - 8 - height
|
|
1409
|
+
if (top < 12) top = Math.min(rect.bottom + 8, Math.max(12, vh - 12 - height))
|
|
1410
|
+
setMetrics(previous => {
|
|
1411
|
+
const box = previous.box
|
|
1412
|
+
if (box !== null && Math.abs(box.left - left) < 0.5
|
|
1413
|
+
&& Math.abs(box.top - top) < 0.5 && Math.abs(box.width - width) < 0.5) return previous
|
|
1414
|
+
return { ...previous, box: { left, top, width } }
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
1417
|
+
place()
|
|
1418
|
+
window.addEventListener('resize', place)
|
|
1419
|
+
window.addEventListener('scroll', place, true)
|
|
1420
|
+
return () => {
|
|
1421
|
+
window.removeEventListener('resize', place)
|
|
1422
|
+
window.removeEventListener('scroll', place, true)
|
|
1423
|
+
}
|
|
1424
|
+
})
|
|
1425
|
+
|
|
1426
|
+
// Re-plan once fonts settle and on panel resize (debounced).
|
|
1427
|
+
useEffect(() => {
|
|
1428
|
+
if (!open) return undefined
|
|
1429
|
+
let timer = 0
|
|
1430
|
+
let cancelled = false
|
|
1431
|
+
const bump = () => {
|
|
1432
|
+
window.clearTimeout(timer)
|
|
1433
|
+
timer = window.setTimeout(() => { setReplan(value => value + 1) }, 80)
|
|
1434
|
+
}
|
|
1435
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(bump) : null
|
|
1436
|
+
if (observer !== null && panelRef.current !== null) observer.observe(panelRef.current)
|
|
1437
|
+
window.addEventListener('resize', bump)
|
|
1438
|
+
if (typeof document !== 'undefined' && document.fonts !== undefined) {
|
|
1439
|
+
document.fonts.ready.then(() => { if (!cancelled) setReplan(value => value + 1) })
|
|
1440
|
+
document.fonts.addEventListener('loadingdone', bump)
|
|
1441
|
+
}
|
|
1442
|
+
return () => {
|
|
1443
|
+
cancelled = true
|
|
1444
|
+
window.clearTimeout(timer)
|
|
1445
|
+
observer?.disconnect()
|
|
1446
|
+
window.removeEventListener('resize', bump)
|
|
1447
|
+
if (typeof document !== 'undefined' && document.fonts !== undefined) {
|
|
1448
|
+
document.fonts.removeEventListener('loadingdone', bump)
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}, [open])
|
|
1452
|
+
|
|
1453
|
+
// Dismissal by outside press.
|
|
1454
|
+
useEffect(() => {
|
|
1455
|
+
if (!open) return undefined
|
|
1456
|
+
const onPointerDown = event => {
|
|
1457
|
+
if (rootRef.current?.contains(event.target) === true) return
|
|
1458
|
+
if (panelRef.current?.contains(event.target) === true) return
|
|
1459
|
+
setOpen(false)
|
|
1460
|
+
}
|
|
1461
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
1462
|
+
return () => { document.removeEventListener('mousedown', onPointerDown) }
|
|
1463
|
+
}, [open])
|
|
1464
|
+
|
|
1465
|
+
// Opening: reset transient state, refresh the directory, focus search.
|
|
1466
|
+
useEffect(() => {
|
|
1467
|
+
if (!open) return undefined
|
|
1468
|
+
setQuery('')
|
|
1469
|
+
setConfirming(false)
|
|
1470
|
+
setTeamFailure(null)
|
|
1471
|
+
load()
|
|
1472
|
+
const timer = window.setTimeout(() => { searchRef.current?.focus() }, 0)
|
|
1473
|
+
return () => { window.clearTimeout(timer) }
|
|
1474
|
+
}, [open, load])
|
|
1475
|
+
|
|
1476
|
+
const close = useCallback((restoreFocus = false) => {
|
|
1477
|
+
setOpen(false)
|
|
1478
|
+
setConfirming(false)
|
|
1479
|
+
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
|
1480
|
+
}, [])
|
|
1481
|
+
|
|
1482
|
+
const submit = useCallback(selection => {
|
|
1483
|
+
setSelectionFocus(true)
|
|
1484
|
+
triggerRef.current?.focus()
|
|
1485
|
+
try {
|
|
1486
|
+
void Promise.resolve(select(selection)).catch(() => { /* surfaced on the store */ })
|
|
1487
|
+
} catch {
|
|
1488
|
+
/* a session that cannot select keeps its previous selection */
|
|
1489
|
+
}
|
|
1490
|
+
}, [select])
|
|
1491
|
+
|
|
1492
|
+
const chooseModel = useCallback((providerId, model) => {
|
|
1493
|
+
if (state.current?.provider === providerId && state.current.model === model.id) {
|
|
1494
|
+
close(true)
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
submit({ provider: providerId, model: model.id })
|
|
1498
|
+
}, [close, state.current, submit])
|
|
1499
|
+
|
|
1500
|
+
const chooseEffort = useCallback(level => {
|
|
1501
|
+
if (state.current === null || effectiveEffort === level) return
|
|
1502
|
+
submit({
|
|
1503
|
+
provider: state.current.provider,
|
|
1504
|
+
model: state.current.model,
|
|
1505
|
+
reasoningEffort: level,
|
|
1506
|
+
})
|
|
1507
|
+
}, [effectiveEffort, state.current, submit])
|
|
1508
|
+
|
|
1509
|
+
const applyTeamMode = useCallback(enabled => {
|
|
1510
|
+
setTeamPending(true)
|
|
1511
|
+
setTeamFailure(null)
|
|
1512
|
+
void setTeamMode(enabled).then(result => {
|
|
1513
|
+
if (result !== undefined && result.ok === false) {
|
|
1514
|
+
const message = result.error?.code === 'session/writer-held'
|
|
1515
|
+
? t('error.sessionInUse')
|
|
1516
|
+
: `${result.error?.code ?? 'error'}: ${result.error?.message ?? ''}`
|
|
1517
|
+
setTeamFailure(fill(t, 'team.failed', { message }))
|
|
1518
|
+
}
|
|
1519
|
+
}, error => {
|
|
1520
|
+
setTeamFailure(fill(t, 'team.failed', {
|
|
1521
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1522
|
+
}))
|
|
1523
|
+
}).finally(() => { setTeamPending(false) })
|
|
1524
|
+
}, [setTeamMode, t])
|
|
1525
|
+
|
|
1526
|
+
const changeTeamMode = useCallback(enabled => {
|
|
1527
|
+
if (!enabled && modelSelection !== undefined && modelSelection.lastUsed !== null) {
|
|
1528
|
+
setConfirming(true)
|
|
1529
|
+
return
|
|
1530
|
+
}
|
|
1531
|
+
applyTeamMode(enabled)
|
|
1532
|
+
}, [applyTeamMode, modelSelection])
|
|
1533
|
+
|
|
1534
|
+
// ---- column planning (the whole point of the layout)
|
|
1535
|
+
// The grid is the only flexible child, so the space it may occupy is the
|
|
1536
|
+
// panel cap minus padding, every present fixed section, and the flex gaps
|
|
1537
|
+
// between them (one gap per fixed section, because the grid always sits
|
|
1538
|
+
// between them).
|
|
1539
|
+
const availableHeight = PANEL_MAX - PANEL_PAD * 2 - metrics.fixed
|
|
1540
|
+
- PANEL_GAP * metrics.fixedCount
|
|
1541
|
+
const units = useMemo(
|
|
1542
|
+
() => buildUnits(sections, metrics.rowH, metrics.headH),
|
|
1543
|
+
[sections, metrics.rowH, metrics.headH],
|
|
1544
|
+
)
|
|
1545
|
+
const plan = useMemo(
|
|
1546
|
+
() => planColumns(units, Math.max(0, availableHeight)),
|
|
1547
|
+
[units, availableHeight, replan],
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
if (!available) return null
|
|
1551
|
+
if (addressedChild || listedChild) return null
|
|
1552
|
+
|
|
1553
|
+
const triggerModel = currentModel === null ? undefined : currentModel.model
|
|
1554
|
+
const waiting = currentModel === null && (state.status === 'loading' || state.status === 'idle')
|
|
1555
|
+
const modelLabel = waiting
|
|
1556
|
+
? t('trigger.loading')
|
|
1557
|
+
: triggerModel?.name ?? t('trigger.fallback')
|
|
1558
|
+
const triggerText = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
1559
|
+
const triggerAria = waiting
|
|
1560
|
+
? t('trigger.loading')
|
|
1561
|
+
: triggerModel === undefined
|
|
1562
|
+
? t('trigger.fallback')
|
|
1563
|
+
: effortLabel === undefined
|
|
1564
|
+
? fill(t, 'trigger.aria', { model: modelLabel })
|
|
1565
|
+
: fill(t, 'trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
|
1566
|
+
|
|
1567
|
+
const badgeOf = model => {
|
|
1568
|
+
const badge = model.badge
|
|
1569
|
+
if (badge === undefined || badge === null) return null
|
|
1570
|
+
const label = resolveText === undefined ? undefined : resolveText(badge.text)
|
|
1571
|
+
if (typeof label !== 'string' || label.trim() === '') return null
|
|
1572
|
+
return { label, tone: badge.tone ?? 'neutral' }
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Split a model name into React children, lifting any "xN" multiplier
|
|
1577
|
+
* marker (rate/size tags like "x0.01", "x1.5", "X 2.2") into a smaller,
|
|
1578
|
+
* bold span so it reads as an aside while the full name and its order are
|
|
1579
|
+
* preserved. Matches an x (any case, or the multiplication sign) that
|
|
1580
|
+
* STARTS a token — preceded by start-of-string or a non-word character,
|
|
1581
|
+
* so it is not embedded in a word like "GenX5" — followed by optional
|
|
1582
|
+
* whitespace and a number (integer or decimal, optional leading +).
|
|
1583
|
+
* "Claude x3", "GPT x0.1", "Model X 2.2" all get the run; a plain
|
|
1584
|
+
* "Model X" (no number) or "GenX5" (x inside a word) do not.
|
|
1585
|
+
*/
|
|
1586
|
+
const MULT_MARKER = /(?<![\p{L}\p{N}])[xX×]\s*[+]?\d+(?:\.\d+)?/gu
|
|
1587
|
+
const nameRuns = rawName => {
|
|
1588
|
+
const name = String(rawName ?? '')
|
|
1589
|
+
const out = []
|
|
1590
|
+
let cursor = 0
|
|
1591
|
+
for (const m of name.matchAll(MULT_MARKER)) {
|
|
1592
|
+
if (m.index > cursor) out.push(name.slice(cursor, m.index))
|
|
1593
|
+
out.push(h('span', { className: 'dshmf-rowNameMark' }, m[0]))
|
|
1594
|
+
cursor = m.index + m[0].length
|
|
1595
|
+
}
|
|
1596
|
+
if (cursor < name.length) out.push(name.slice(cursor))
|
|
1597
|
+
return out
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const renderRow = entry => {
|
|
1601
|
+
const { model, providerId, providerLabel, rowVariant } = entry
|
|
1602
|
+
const selected = state.current?.provider === providerId && state.current.model === model.id
|
|
1603
|
+
const pendingThis = state.pending?.provider === providerId && state.pending.model === model.id
|
|
1604
|
+
const badge = badgeOf(model)
|
|
1605
|
+
/**
|
|
1606
|
+
* Under family grouping the heading already says what the models ARE,
|
|
1607
|
+
* so the family name is not repeated in a row. The row's primary text
|
|
1608
|
+
* is the PROVIDER — the one thing a heading cannot tell you when a
|
|
1609
|
+
* family spans providers. Only when one provider offers several models
|
|
1610
|
+
* in this family does a short variant tail differentiate them. Under
|
|
1611
|
+
* provider grouping the heading IS the provider, so the primary text
|
|
1612
|
+
* stays the model name and no secondary tag is drawn.
|
|
1613
|
+
*/
|
|
1614
|
+
const familyMode = grouping === 'family'
|
|
1615
|
+
const primary = familyMode && typeof providerLabel === 'string' && providerLabel !== ''
|
|
1616
|
+
? providerLabel : String(model.name ?? '')
|
|
1617
|
+
const showVariant = familyMode && typeof rowVariant === 'string' && rowVariant !== ''
|
|
1618
|
+
const rowTitle = model.description === undefined
|
|
1619
|
+
? `${model.name}\n${providerLabel}`
|
|
1620
|
+
: `${model.name}\n${model.description}\n${providerLabel}`
|
|
1621
|
+
// The model's last call ran out of balance and nothing has cleared it
|
|
1622
|
+
// yet: the row text is struck through until a later call for the same
|
|
1623
|
+
// model route returns anything else.
|
|
1624
|
+
const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
|
|
1625
|
+
return h('button', {
|
|
1626
|
+
key: `${providerId}/${model.id}`,
|
|
1627
|
+
type: 'button',
|
|
1628
|
+
className: 'dshmf-row',
|
|
1629
|
+
'aria-pressed': selected,
|
|
1630
|
+
'data-insufficient': insufficientRoute ? 'true' : undefined,
|
|
1631
|
+
title: rowTitle,
|
|
1632
|
+
disabled: busy,
|
|
1633
|
+
onClick: () => { chooseModel(providerId, model) },
|
|
1634
|
+
},
|
|
1635
|
+
// `nameRuns` (xN multiplier markers) is applied to whatever the
|
|
1636
|
+
// primary text is: the model name under provider grouping, or the
|
|
1637
|
+
// provider name under family grouping.
|
|
1638
|
+
h('span', { className: 'dshmf-rowName' }, ...nameRuns(primary)),
|
|
1639
|
+
showVariant ? h('span', {
|
|
1640
|
+
className: 'dshmf-rowProvider',
|
|
1641
|
+
title: rowVariant,
|
|
1642
|
+
}, rowVariant) : null,
|
|
1643
|
+
badge === null ? null : h('span', {
|
|
1644
|
+
className: 'dshmf-badge',
|
|
1645
|
+
'data-tone': badge.tone,
|
|
1646
|
+
}, badge.label),
|
|
1647
|
+
pendingThis ? h('span', { className: 'dshmf-rowTail' }, h('span', { className: 'dshmf-spin' })) : null,
|
|
1648
|
+
)
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const showEmptyState = sections.length === 0 && !busy
|
|
1652
|
+
const emptyKey = state.status === 'loading' && totalModels === 0
|
|
1653
|
+
? 'status.loading'
|
|
1654
|
+
: totalModels === 0 ? 'empty.models' : 'empty.search'
|
|
1655
|
+
|
|
1656
|
+
const renderBlock = block => (block.kind === 'title'
|
|
1657
|
+
? h('div', {
|
|
1658
|
+
className: 'dshmf-groupTitle',
|
|
1659
|
+
key: block.key,
|
|
1660
|
+
title: block.label,
|
|
1661
|
+
}, block.label)
|
|
1662
|
+
: renderRow(block.entry))
|
|
1663
|
+
|
|
1664
|
+
const renderColumn = (column, columnIndex) => h('div', {
|
|
1665
|
+
className: 'dshmf-col' + (plan.count > 1 && columnIndex < plan.count - 1 ? ' dshmf-col--divided' : ''),
|
|
1666
|
+
key: `col-${columnIndex}`,
|
|
1667
|
+
}, column.map(unit => unit.blocks.map(renderBlock)))
|
|
1668
|
+
|
|
1669
|
+
const renderFailure = failure => h('div', {
|
|
1670
|
+
className: 'dshmf-failure',
|
|
1671
|
+
key: `failure-${failure.id}`,
|
|
1672
|
+
role: 'status',
|
|
1673
|
+
}, fill(t, 'failure.group', {
|
|
1674
|
+
name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name,
|
|
1675
|
+
message: failure.message,
|
|
1676
|
+
}))
|
|
1677
|
+
|
|
1678
|
+
const onPanelKeyDown = event => {
|
|
1679
|
+
if (event.key !== 'Escape') return
|
|
1680
|
+
event.preventDefault()
|
|
1681
|
+
if (confirming) {
|
|
1682
|
+
setConfirming(false)
|
|
1683
|
+
return
|
|
1684
|
+
}
|
|
1685
|
+
close(true)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Radio-group keyboard: arrows move the exclusive selection to the
|
|
1689
|
+
// neighbouring enabled level, so the whole group is reachable by keys.
|
|
1690
|
+
const onEffortKeyDown = event => {
|
|
1691
|
+
if (event.key !== 'ArrowRight' && event.key !== 'ArrowDown'
|
|
1692
|
+
&& event.key !== 'ArrowLeft' && event.key !== 'ArrowUp') return
|
|
1693
|
+
const pills = reasonRef.current
|
|
1694
|
+
? [...reasonRef.current.querySelectorAll('button.dshmf-pill:not(:disabled)')]
|
|
1695
|
+
: []
|
|
1696
|
+
if (pills.length === 0) return
|
|
1697
|
+
event.preventDefault()
|
|
1698
|
+
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
|
1699
|
+
const at = event.target instanceof Element ? pills.indexOf(event.target) : -1
|
|
1700
|
+
const next = at === -1
|
|
1701
|
+
? pills[forward ? 0 : pills.length - 1]
|
|
1702
|
+
: pills[(at + (forward ? 1 : -1) + pills.length) % pills.length]
|
|
1703
|
+
next?.focus()
|
|
1704
|
+
next?.click()
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
return h('div', {
|
|
1708
|
+
className: 'dshmf-root',
|
|
1709
|
+
ref: rootRef,
|
|
1710
|
+
onMouseDown: event => {
|
|
1711
|
+
// WebKit blurs a focused row before click unless mousedown keeps focus.
|
|
1712
|
+
if (event.target instanceof Element && event.target.closest('button') !== null) event.preventDefault()
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
h('button', {
|
|
1716
|
+
ref: triggerRef,
|
|
1717
|
+
type: 'button',
|
|
1718
|
+
className: 'dshmf-trigger',
|
|
1719
|
+
'aria-label': triggerAria,
|
|
1720
|
+
'aria-haspopup': 'dialog',
|
|
1721
|
+
'aria-expanded': open,
|
|
1722
|
+
'aria-controls': open ? `${id}-panel` : undefined,
|
|
1723
|
+
title: triggerText,
|
|
1724
|
+
'aria-busy': busy,
|
|
1725
|
+
'data-selection-focus': selectionFocus ? '' : undefined,
|
|
1726
|
+
disabled: locked,
|
|
1727
|
+
onBlur: () => { setSelectionFocus(false) },
|
|
1728
|
+
onClick: () => {
|
|
1729
|
+
if (open) {
|
|
1730
|
+
close(true)
|
|
1731
|
+
return
|
|
1732
|
+
}
|
|
1733
|
+
setSelectionFocus(false)
|
|
1734
|
+
triggerRef.current?.focus()
|
|
1735
|
+
setOpen(true)
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
|
|
1739
|
+
h('span', { className: 'dshmf-triggerText' }, modelLabel),
|
|
1740
|
+
effortLabel === undefined ? null : h('span', { className: 'dshmf-triggerEffort' }, effortLabel),
|
|
1741
|
+
busy
|
|
1742
|
+
? h('span', { className: 'dshmf-spin' })
|
|
1743
|
+
: svg(PATH_CHEVRON, 12, { className: `dshmf-chevron${open ? ' dshmf-chevronOpen' : ''}` }),
|
|
1744
|
+
),
|
|
1745
|
+
|
|
1746
|
+
open ? ReactDOM.createPortal(
|
|
1747
|
+
h('div', {
|
|
1748
|
+
ref: panelRef,
|
|
1749
|
+
id: `${id}-panel`,
|
|
1750
|
+
className: 'dshmf-panel',
|
|
1751
|
+
style: {
|
|
1752
|
+
left: `${metrics.box?.left ?? 12}px`,
|
|
1753
|
+
top: `${metrics.box?.top ?? 12}px`,
|
|
1754
|
+
width: `${metrics.box?.width ?? 320}px`,
|
|
1755
|
+
},
|
|
1756
|
+
role: 'dialog',
|
|
1757
|
+
'aria-label': t('panel.aria'),
|
|
1758
|
+
onKeyDown: onPanelKeyDown,
|
|
1759
|
+
},
|
|
1760
|
+
// ---- fixed: search + grouping segment
|
|
1761
|
+
h('div', { className: 'dshmf-head', ref: headRef },
|
|
1762
|
+
h('div', { className: 'dshmf-search' },
|
|
1763
|
+
h('input', {
|
|
1764
|
+
ref: searchRef,
|
|
1765
|
+
className: 'dshmf-input',
|
|
1766
|
+
type: 'text',
|
|
1767
|
+
role: 'searchbox',
|
|
1768
|
+
'aria-label': t('search.placeholder'),
|
|
1769
|
+
placeholder: t('search.placeholder'),
|
|
1770
|
+
value: query,
|
|
1771
|
+
readOnly: busy,
|
|
1772
|
+
onChange: event => { setQuery(event.target.value) },
|
|
1773
|
+
}),
|
|
1774
|
+
state.status === 'loading'
|
|
1775
|
+
? h('span', { className: 'dshmf-spin', title: t('status.loading') })
|
|
1776
|
+
: null,
|
|
1777
|
+
query === '' ? null : h('button', {
|
|
1778
|
+
type: 'button',
|
|
1779
|
+
className: 'dshmf-clear',
|
|
1780
|
+
'aria-label': t('search.clear'),
|
|
1781
|
+
title: t('search.clear'),
|
|
1782
|
+
onClick: () => {
|
|
1783
|
+
setQuery('')
|
|
1784
|
+
searchRef.current?.focus()
|
|
1785
|
+
},
|
|
1786
|
+
}, svg(PATH_CLOSE, 10)),
|
|
1787
|
+
),
|
|
1788
|
+
h('div', { className: 'dshmf-seg', role: 'group', 'aria-label': t('group.aria') },
|
|
1789
|
+
h('button', {
|
|
1790
|
+
type: 'button',
|
|
1791
|
+
className: 'dshmf-segBtn',
|
|
1792
|
+
'aria-pressed': grouping === 'provider',
|
|
1793
|
+
title: t('group.provider'),
|
|
1794
|
+
onClick: () => { setGrouping('provider'); writeGrouping('provider') },
|
|
1795
|
+
}, t('group.provider')),
|
|
1796
|
+
h('button', {
|
|
1797
|
+
type: 'button',
|
|
1798
|
+
className: 'dshmf-segBtn',
|
|
1799
|
+
'aria-pressed': grouping === 'family',
|
|
1800
|
+
title: t('group.family'),
|
|
1801
|
+
onClick: () => { setGrouping('family'); writeGrouping('family') },
|
|
1802
|
+
}, t('group.family')),
|
|
1803
|
+
),
|
|
1804
|
+
),
|
|
1805
|
+
|
|
1806
|
+
// ---- fixed: failure strip above the grid, so it never competes
|
|
1807
|
+
// with model rows for the scroll area
|
|
1808
|
+
state.error === null ? null : h('div', {
|
|
1809
|
+
className: 'dshmf-error',
|
|
1810
|
+
ref: errorRef,
|
|
1811
|
+
role: 'status',
|
|
1812
|
+
}, fill(t, 'error.action', {
|
|
1813
|
+
message: state.error.includes('session/writer-held')
|
|
1814
|
+
? t('error.sessionInUse')
|
|
1815
|
+
: state.error,
|
|
1816
|
+
})),
|
|
1817
|
+
|
|
1818
|
+
// ---- the only scrolling region
|
|
1819
|
+
h('div', {
|
|
1820
|
+
className: 'dshmf-grid',
|
|
1821
|
+
style: {
|
|
1822
|
+
gridTemplateColumns: `repeat(${plan.count}, minmax(0, 1fr))`,
|
|
1823
|
+
columnGap: `${COL_GAP}px`,
|
|
1824
|
+
},
|
|
1825
|
+
},
|
|
1826
|
+
showEmptyState
|
|
1827
|
+
? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
|
|
1828
|
+
: plan.columns.map(renderColumn),
|
|
1829
|
+
state.failures.length === 0 ? null : state.failures.map(renderFailure),
|
|
1830
|
+
),
|
|
1831
|
+
|
|
1832
|
+
// ---- fixed: reasoning levels of the current model, rendered as
|
|
1833
|
+
// one labelled group of mutually exclusive choices
|
|
1834
|
+
// ---- fixed, bottom: the agent teamwork switch. `teamAvailable`
|
|
1835
|
+
// is evaluated per render because the remote scope may bind late.
|
|
1836
|
+
// Both live in ONE footer flex container: they share a line when
|
|
1837
|
+
// they fit with the 30px gap, otherwise each takes its own line.
|
|
1838
|
+
(effortChoices.length === 0 && (teamMode === undefined || !teamAvailable()))
|
|
1839
|
+
? null
|
|
1840
|
+
: h('div', { className: 'dshmf-footer', ref: footerRef },
|
|
1841
|
+
effortChoices.length === 0 ? null : h('div', {
|
|
1842
|
+
className: 'dshmf-reason',
|
|
1843
|
+
ref: reasonRef,
|
|
1844
|
+
role: 'radiogroup',
|
|
1845
|
+
'aria-labelledby': `${id}-effort-label`,
|
|
1846
|
+
onKeyDown: onEffortKeyDown,
|
|
1847
|
+
},
|
|
1848
|
+
h('span', {
|
|
1849
|
+
className: 'dshmf-reasonLabel',
|
|
1850
|
+
id: `${id}-effort-label`,
|
|
1851
|
+
title: t('effort.hint'),
|
|
1852
|
+
}, svg(PATH_SPARK, 12), t('effort.label')),
|
|
1853
|
+
h('div', { className: 'dshmf-pills' }, effortChoices.map(level => {
|
|
1854
|
+
const selected = effectiveEffort === level.id
|
|
1855
|
+
const pendingThis = state.pending !== null
|
|
1856
|
+
&& state.pending.provider === state.current?.provider
|
|
1857
|
+
&& state.pending.model === state.current.model
|
|
1858
|
+
&& state.pending.reasoningEffort === level.id
|
|
1859
|
+
return h('button', {
|
|
1860
|
+
key: level.id,
|
|
1861
|
+
type: 'button',
|
|
1862
|
+
role: 'radio',
|
|
1863
|
+
className: 'dshmf-pill',
|
|
1864
|
+
'aria-checked': selected,
|
|
1865
|
+
title: level.description ?? level.name,
|
|
1866
|
+
disabled: busy || state.current === null,
|
|
1867
|
+
onClick: () => { chooseEffort(level.id) },
|
|
1868
|
+
}, pendingThis
|
|
1869
|
+
? h('span', { className: 'dshmf-spin' })
|
|
1870
|
+
: h('span', { className: 'dshmf-pillName' }, level.name))
|
|
1871
|
+
})),
|
|
1872
|
+
),
|
|
1873
|
+
teamMode === undefined || !teamAvailable()
|
|
1874
|
+
? null
|
|
1875
|
+
: h('div', { className: 'dshmf-team', ref: teamRef },
|
|
1876
|
+
h('span', { className: 'dshmf-teamLabel' },
|
|
1877
|
+
h('span', { className: 'dshmf-teamName' }, t('team.label')),
|
|
1878
|
+
h('span', {
|
|
1879
|
+
className: 'dshmf-info',
|
|
1880
|
+
title: t('team.description'),
|
|
1881
|
+
'aria-hidden': 'true',
|
|
1882
|
+
}, svg(PATH_INFO, 12)),
|
|
1883
|
+
),
|
|
1884
|
+
h('button', {
|
|
1885
|
+
type: 'button',
|
|
1886
|
+
role: 'switch',
|
|
1887
|
+
className: 'dshmf-switch',
|
|
1888
|
+
'aria-checked': teamMode.enabled,
|
|
1889
|
+
'aria-label': t('team.label'),
|
|
1890
|
+
title: t('team.description'),
|
|
1891
|
+
disabled: locked || busy || teamPending || modelSelection === undefined,
|
|
1892
|
+
onClick: () => { changeTeamMode(!teamMode.enabled) },
|
|
1893
|
+
}, h('span', { className: 'dshmf-thumb' })),
|
|
1894
|
+
),
|
|
1895
|
+
),
|
|
1896
|
+
|
|
1897
|
+
// ---- fixed: teamwork disable confirmation
|
|
1898
|
+
confirming ? h('div', {
|
|
1899
|
+
className: 'dshmf-confirm',
|
|
1900
|
+
ref: confirmRef,
|
|
1901
|
+
role: 'alertdialog',
|
|
1902
|
+
'aria-label': t('team.disable.title'),
|
|
1903
|
+
},
|
|
1904
|
+
h('span', { className: 'dshmf-confirmTitle' }, t('team.disable.title')),
|
|
1905
|
+
h('span', { className: 'dshmf-confirmBody' },
|
|
1906
|
+
`${t('team.disable.description')} ${t('team.disable.warning')}`),
|
|
1907
|
+
h('div', { className: 'dshmf-confirmActions' },
|
|
1908
|
+
h('button', {
|
|
1909
|
+
type: 'button',
|
|
1910
|
+
className: 'dshmf-confirmBtn',
|
|
1911
|
+
onClick: () => { setConfirming(false) },
|
|
1912
|
+
}, t('team.disable.cancel')),
|
|
1913
|
+
h('button', {
|
|
1914
|
+
type: 'button',
|
|
1915
|
+
className: 'dshmf-confirmBtn',
|
|
1916
|
+
'data-primary': 'true',
|
|
1917
|
+
onClick: () => {
|
|
1918
|
+
setConfirming(false)
|
|
1919
|
+
applyTeamMode(false)
|
|
1920
|
+
},
|
|
1921
|
+
}, t('team.disable.confirm')),
|
|
1922
|
+
),
|
|
1923
|
+
) : null,
|
|
1924
|
+
|
|
1925
|
+
// ---- fixed: teamwork failure strip
|
|
1926
|
+
teamFailure === null ? null : h('div', {
|
|
1927
|
+
className: 'dshmf-error',
|
|
1928
|
+
ref: teamErrorRef,
|
|
1929
|
+
role: 'status',
|
|
1930
|
+
}, teamFailure),
|
|
1931
|
+
|
|
1932
|
+
// ---- measurement probe (never painted). It mirrors a family-
|
|
1933
|
+
// grouped row (provider primary + variant tag), the widest row
|
|
1934
|
+
// shape, so the measured row height covers every mode. It carries
|
|
1935
|
+
// no trailing tail: the selected row is a filled band now and the
|
|
1936
|
+
// spinner is only drawn for a pending selection.
|
|
1937
|
+
h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
|
|
1938
|
+
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' }, 'X'),
|
|
1939
|
+
h('div', { ref: probeRowRef, className: 'dshmf-row' },
|
|
1940
|
+
h('span', { className: 'dshmf-rowName' }, 'X'),
|
|
1941
|
+
h('span', { className: 'dshmf-rowProvider' }, 'X'),
|
|
1942
|
+
),
|
|
1943
|
+
),
|
|
1944
|
+
),
|
|
1945
|
+
document.body,
|
|
1946
|
+
) : null,
|
|
1947
|
+
)
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// ---------------------------------------------------------------------
|
|
1951
|
+
// Seat boundary
|
|
1952
|
+
// ---------------------------------------------------------------------
|
|
1953
|
+
/**
|
|
1954
|
+
* Keep the composer seat alive when its content fails.
|
|
1955
|
+
*
|
|
1956
|
+
* A shadowing slot entry that throws during render abdicates, and the cell
|
|
1957
|
+
* falls to the next registration — the native picker reappears and this
|
|
1958
|
+
* plugin looks like it never installed. Catching here turns any failure into
|
|
1959
|
+
* a small visible chip, so the seat never disappears and the reason stays in
|
|
1960
|
+
* the console.
|
|
1961
|
+
*/
|
|
1962
|
+
class SeatBoundary extends React.Component {
|
|
1963
|
+
constructor(props) {
|
|
1964
|
+
super(props)
|
|
1965
|
+
this.state = { failure: null }
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
static getDerivedStateFromError(error) {
|
|
1969
|
+
return { failure: error instanceof Error ? error : new Error(String(error)) }
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
componentDidCatch(error, info) {
|
|
1973
|
+
console.error('dsh-model-flatmap: the model seat failed and fell back to a minimal control', error, info)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
render() {
|
|
1977
|
+
const { failure } = this.state
|
|
1978
|
+
if (failure === null) return this.props.children
|
|
1979
|
+
const label = this.props.label ?? 'Model'
|
|
1980
|
+
// The reason is shown, not just logged: without browser console access a
|
|
1981
|
+
// silent fallback is indistinguishable from "the plugin never installed".
|
|
1982
|
+
const reason = String(failure && failure.message ? failure.message : failure)
|
|
1983
|
+
return h('div', { className: 'dshmf-root' },
|
|
1984
|
+
h('button', {
|
|
1985
|
+
type: 'button',
|
|
1986
|
+
className: 'dshmf-trigger',
|
|
1987
|
+
disabled: true,
|
|
1988
|
+
title: `${label}: ${reason}`,
|
|
1989
|
+
'aria-label': `${label} — ${reason}`,
|
|
1990
|
+
},
|
|
1991
|
+
svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
|
|
1992
|
+
h('span', { className: 'dshmf-triggerText' }, `${label}: ${reason.slice(0, 90)}`),
|
|
1993
|
+
),
|
|
1994
|
+
)
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/** Labels whose derivation failure has already been reported. */
|
|
1999
|
+
const reportedDerivations = new Set()
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Run a pure derivation and degrade instead of throwing.
|
|
2003
|
+
* @param label - which derivation failed, for the console.
|
|
2004
|
+
* @param run - the derivation.
|
|
2005
|
+
* @param fallback - the value to use when it throws.
|
|
2006
|
+
* @returns the derived value, or the fallback.
|
|
2007
|
+
*/
|
|
2008
|
+
function guarded(label, run, fallback) {
|
|
2009
|
+
try {
|
|
2010
|
+
return run()
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
if (!reportedDerivations.has(label)) {
|
|
2013
|
+
reportedDerivations.add(label)
|
|
2014
|
+
console.error(`dsh-model-flatmap: ${label} failed; the panel degrades`, error)
|
|
2015
|
+
}
|
|
2016
|
+
return fallback
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* The registered seat: a boundary around the panel, so a content failure
|
|
2022
|
+
* cannot remove the entry from the slot.
|
|
2023
|
+
* @param props - the slot's standard props plus the injected face.
|
|
2024
|
+
*/
|
|
2025
|
+
function Seat(props) {
|
|
2026
|
+
const translate = typeof props.t === 'function' ? props.t : key => key
|
|
2027
|
+
return h(SeatBoundary, { label: translate('trigger.fallback') },
|
|
2028
|
+
h(ModelFlatMap, props))
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// ---------------------------------------------------------------------
|
|
2032
|
+
// Plugin
|
|
2033
|
+
// ---------------------------------------------------------------------
|
|
2034
|
+
return {
|
|
2035
|
+
// `remote.agentTeams` is a SEPARATE client service (the gateway installs
|
|
2036
|
+
// each remote namespace as its own service). Reading it through a bare
|
|
2037
|
+
// `ctx.get('remote')` property lookup is refused by the guarded client
|
|
2038
|
+
// context unless both names are declared here, and a swallowed refusal
|
|
2039
|
+
// used to leave `teamAvailable` permanently false — i.e. the host's
|
|
2040
|
+
// teamwork switch never rendered at all.
|
|
2041
|
+
inject: ['slots', 'locale', 'remote', 'remote.agentTeams', 'sessions'],
|
|
2042
|
+
// Pure layout and grouping derivations plus the component itself,
|
|
2043
|
+
// exposed for `node --test` so grouping, filtering and the column
|
|
2044
|
+
// algorithm are pinned without a browser. The shell never reads this key.
|
|
2045
|
+
__test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, foldInsufficient, routeOfHeader, routeKeyOf },
|
|
2046
|
+
__component: ModelFlatMap,
|
|
2047
|
+
__seat: Seat,
|
|
2048
|
+
apply(ctx) {
|
|
2049
|
+
const read = name => {
|
|
2050
|
+
try {
|
|
2051
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
2052
|
+
} catch {
|
|
2053
|
+
return undefined
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Dictionary registration must never be able to take the seat down: a
|
|
2058
|
+
// namespace left behind by a previous module generation throws on
|
|
2059
|
+
// re-registration, and losing the copy is far better than losing the
|
|
2060
|
+
// control.
|
|
2061
|
+
try {
|
|
2062
|
+
const locale = read('locale')
|
|
2063
|
+
if (locale !== undefined && typeof locale.register === 'function') {
|
|
2064
|
+
ctx.effect(() => locale.register(NS, dict), 'dsh-model-flatmap: dictionaries')
|
|
2065
|
+
}
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
console.error('dsh-model-flatmap: dictionaries were not registered', error)
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const resolveText = value => {
|
|
2071
|
+
try {
|
|
2072
|
+
const locale = read('locale')
|
|
2073
|
+
return locale === undefined ? fallbackText(value) : locale.resolveText(value)
|
|
2074
|
+
} catch {
|
|
2075
|
+
return fallbackText(value)
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Services are read at CALL time, never captured once: a client reload
|
|
2080
|
+
// replaces them, and a value captured during activation then throws
|
|
2081
|
+
// forever. `slots.inject` additionally waits for the declaration, so the
|
|
2082
|
+
// registration below runs exactly when the seat exists.
|
|
2083
|
+
const slots = read('slots')
|
|
2084
|
+
if (slots === undefined || typeof slots.inject !== 'function') return
|
|
2085
|
+
|
|
2086
|
+
// The teamwork switch is driven by the Host's own `remote.agentTeams`
|
|
2087
|
+
// namespace service. The gateway installs each remote namespace as its
|
|
2088
|
+
// OWN service, so it is read by full name through `ctx.get`, never as a
|
|
2089
|
+
// property of the `remote` service (a bare `ctx.remote` lookup is
|
|
2090
|
+
// refused by the guarded client context, and the old try/catch turned
|
|
2091
|
+
// that refusal into a silent `undefined` — which hid the switch
|
|
2092
|
+
// forever). `ctx.inject` is not among the client facade's verbs, so the
|
|
2093
|
+
// handle is resolved lazily on every use instead of once at activation.
|
|
2094
|
+
const readTeamService = () => {
|
|
2095
|
+
try {
|
|
2096
|
+
return typeof ctx.get === 'function' ? ctx.get('remote.agentTeams') : undefined
|
|
2097
|
+
} catch {
|
|
2098
|
+
return undefined
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Build the injected face for one Session.
|
|
2104
|
+
* @param sessionId - the Session this seat renders for.
|
|
2105
|
+
* @returns the face the seat component receives.
|
|
2106
|
+
*/
|
|
2107
|
+
const buildFace = sessionId => {
|
|
2108
|
+
// Every capability is resolved per call, so a Session that binds late,
|
|
2109
|
+
// or a reloaded service, is picked up without tearing the seat down.
|
|
2110
|
+
const safeRead = name => {
|
|
2111
|
+
try {
|
|
2112
|
+
return read(name)
|
|
2113
|
+
} catch {
|
|
2114
|
+
return undefined
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
const models = safeRead('modelDirectories')
|
|
2118
|
+
const sessions = safeRead('sessions')
|
|
2119
|
+
|
|
2120
|
+
const resolve = () => {
|
|
2121
|
+
try {
|
|
2122
|
+
// `subagentAddress` is the documented way to tell an addressed
|
|
2123
|
+
// child from an ordinary Session; without it, treat the Session as
|
|
2124
|
+
// selectable rather than blanking the composer.
|
|
2125
|
+
if (typeof sessions?.subagentAddress === 'function'
|
|
2126
|
+
&& sessions.subagentAddress(sessionId) !== undefined) {
|
|
2127
|
+
return { addressed: true }
|
|
2128
|
+
}
|
|
2129
|
+
if (models === undefined || typeof models.directoryFor !== 'function') {
|
|
2130
|
+
return { addressed: false, pending: 'modelDirectories unavailable' }
|
|
2131
|
+
}
|
|
2132
|
+
const directory = models.directoryFor(sessionId)
|
|
2133
|
+
return { addressed: false, directory, store: directory.store }
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
return { addressed: false, pending: error instanceof Error ? error.message : String(error) }
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const first = resolve()
|
|
2139
|
+
|
|
2140
|
+
// ---- insufficient-balance marks, folded from the live event feed
|
|
2141
|
+
//
|
|
2142
|
+
// `sessions.binding(id).eventSource` is the Client's contiguous
|
|
2143
|
+
// durable event window (the same feed the Chat reads `turn/end` from).
|
|
2144
|
+
// The face keeps ONE immutable snapshot object and replaces it only
|
|
2145
|
+
// when a fold actually changes the marks, so `useSyncExternalStore`
|
|
2146
|
+
// re-renders the panel on a real transition and stays quiet on every
|
|
2147
|
+
// other event.
|
|
2148
|
+
const insufficientListeners = new Set()
|
|
2149
|
+
let insufficientMarks = new Map()
|
|
2150
|
+
let insufficientSnapshot = { marks: insufficientMarks }
|
|
2151
|
+
// The route of the latest request header: a `turn/end` belongs to the
|
|
2152
|
+
// model that request was dispatched to.
|
|
2153
|
+
let pendingRoute = null
|
|
2154
|
+
let stopInsufficient = null
|
|
2155
|
+
|
|
2156
|
+
/** Publish a new snapshot only when the mark set really changed. */
|
|
2157
|
+
const publishInsufficient = () => {
|
|
2158
|
+
insufficientSnapshot = { marks: insufficientMarks }
|
|
2159
|
+
for (const listener of insufficientListeners) listener()
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/** Read the durable entries out of the current window snapshot. */
|
|
2163
|
+
const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Fold one batch of durable entries into the marks. Live chunks carry
|
|
2167
|
+
* no `turn/end`, so they are skipped by type inside the fold.
|
|
2168
|
+
* @param entries - durable event entries from the feed.
|
|
2169
|
+
* @returns whether any mark changed.
|
|
2170
|
+
*/
|
|
2171
|
+
const absorb = entries => {
|
|
2172
|
+
let changed = false
|
|
2173
|
+
for (const entry of entries) {
|
|
2174
|
+
const event = entry?.event
|
|
2175
|
+
if (event === null || event === undefined) continue
|
|
2176
|
+
const header = routeOfHeader(event)
|
|
2177
|
+
if (header !== null) {
|
|
2178
|
+
pendingRoute = header
|
|
2179
|
+
continue
|
|
2180
|
+
}
|
|
2181
|
+
if (event.type !== 'turn/end') continue
|
|
2182
|
+
if (foldInsufficient(insufficientMarks, event, pendingRoute)) changed = true
|
|
2183
|
+
}
|
|
2184
|
+
return changed
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* Attach the feed listener. A `replace` (initial load, history
|
|
2189
|
+
* replacement) refolds from scratch, because the window it describes
|
|
2190
|
+
* is the whole truth; an `append` folds only the new entries. A
|
|
2191
|
+
* `prepend` loads OLDER history at the front and cannot change a mark
|
|
2192
|
+
* (marks come from the tail), so it is ignored rather than refolded —
|
|
2193
|
+
* refolding would also re-walk history on every page-in.
|
|
2194
|
+
*/
|
|
2195
|
+
const startInsufficientFeed = () => {
|
|
2196
|
+
if (stopInsufficient !== null) return
|
|
2197
|
+
let binding
|
|
2198
|
+
try {
|
|
2199
|
+
binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
|
|
2200
|
+
} catch {
|
|
2201
|
+
binding = undefined
|
|
2202
|
+
}
|
|
2203
|
+
const source = binding?.eventSource
|
|
2204
|
+
if (source === undefined || typeof source.subscribe !== 'function'
|
|
2205
|
+
|| typeof source.getSnapshot !== 'function') return
|
|
2206
|
+
/** Whether two mark maps hold the same entries. */
|
|
2207
|
+
const sameMarks = (left, right) => {
|
|
2208
|
+
if (left.size !== right.size) return false
|
|
2209
|
+
for (const [key, value] of left) {
|
|
2210
|
+
if (right.get(key) !== value) return false
|
|
2211
|
+
}
|
|
2212
|
+
return true
|
|
2213
|
+
}
|
|
2214
|
+
const applyWindow = (window, full) => {
|
|
2215
|
+
const change = window?.change
|
|
2216
|
+
if (full || change?.kind === 'replace') {
|
|
2217
|
+
const previous = insufficientMarks
|
|
2218
|
+
const next = new Map()
|
|
2219
|
+
insufficientMarks = next
|
|
2220
|
+
pendingRoute = null
|
|
2221
|
+
absorb(entriesOf(window))
|
|
2222
|
+
// Adopt the refolded map as the snapshot either way, so the
|
|
2223
|
+
// snapshot and the live state never diverge; only NOTIFY when
|
|
2224
|
+
// the marks really changed, so a history replacement that
|
|
2225
|
+
// re-derives the same marks does not repaint the panel.
|
|
2226
|
+
insufficientSnapshot = { marks: next }
|
|
2227
|
+
if (!sameMarks(previous, next)) publishInsufficient()
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
if (change?.kind === 'append' && absorb(change.entries)) publishInsufficient()
|
|
2231
|
+
}
|
|
2232
|
+
try {
|
|
2233
|
+
applyWindow(source.getSnapshot(), true)
|
|
2234
|
+
} catch {
|
|
2235
|
+
/* an unreadable feed leaves the panel unmarked, never broken */
|
|
2236
|
+
}
|
|
2237
|
+
const listener = () => {
|
|
2238
|
+
try {
|
|
2239
|
+
applyWindow(source.getSnapshot(), false)
|
|
2240
|
+
} catch {
|
|
2241
|
+
/* a fold failure must not escape into the slot */
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
try {
|
|
2245
|
+
const stop = source.subscribe(listener)
|
|
2246
|
+
stopInsufficient = typeof stop === 'function' ? stop : () => {}
|
|
2247
|
+
} catch {
|
|
2248
|
+
stopInsufficient = null
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
startInsufficientFeed()
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* The seat's read face for the marks. `subscribe` and `getSnapshot`
|
|
2256
|
+
* are the `useSyncExternalStore` pair; the snapshot object is stable
|
|
2257
|
+
* between real transitions.
|
|
2258
|
+
*/
|
|
2259
|
+
const insufficient = {
|
|
2260
|
+
subscribe: listener => {
|
|
2261
|
+
insufficientListeners.add(listener)
|
|
2262
|
+
// The binding may have materialized after the face was built.
|
|
2263
|
+
startInsufficientFeed()
|
|
2264
|
+
return () => { insufficientListeners.delete(listener) }
|
|
2265
|
+
},
|
|
2266
|
+
getSnapshot: () => insufficientSnapshot,
|
|
2267
|
+
/** Stop observing the feed; called when the seat's owner disposes. */
|
|
2268
|
+
dispose: () => {
|
|
2269
|
+
if (stopInsufficient !== null) stopInsufficient()
|
|
2270
|
+
stopInsufficient = null
|
|
2271
|
+
insufficientListeners.clear()
|
|
2272
|
+
},
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
return {
|
|
2276
|
+
sessionId,
|
|
2277
|
+
resolveDirectory: resolve,
|
|
2278
|
+
safeRead,
|
|
2279
|
+
insufficient,
|
|
2280
|
+
// `available: false` only for an addressed subagent Session, which
|
|
2281
|
+
// the shipped seat also hides.
|
|
2282
|
+
available: first.addressed !== true,
|
|
2283
|
+
directory: first.store,
|
|
2284
|
+
load: () => {
|
|
2285
|
+
const current = resolve()
|
|
2286
|
+
if (current.directory !== undefined) {
|
|
2287
|
+
current.directory.load().catch(() => { /* surfaced on the store */ })
|
|
2288
|
+
}
|
|
2289
|
+
},
|
|
2290
|
+
select: selection => {
|
|
2291
|
+
const current = resolve()
|
|
2292
|
+
return current.directory === undefined
|
|
2293
|
+
? Promise.resolve(undefined)
|
|
2294
|
+
: current.directory.select(selection)
|
|
2295
|
+
},
|
|
2296
|
+
resolveText,
|
|
2297
|
+
// Lazy, not snapshotted: the namespace service may still be
|
|
2298
|
+
// mounting when this face is built, and the seat is registered
|
|
2299
|
+
// once. Re-reading on each use keeps the switch from being frozen
|
|
2300
|
+
// out by an early `undefined`.
|
|
2301
|
+
teamAvailable: () => readTeamService() !== undefined,
|
|
2302
|
+
setTeamMode: enabled => {
|
|
2303
|
+
const service = readTeamService()
|
|
2304
|
+
return service === undefined
|
|
2305
|
+
? Promise.resolve(undefined)
|
|
2306
|
+
: service.setEnabled(sessionId, enabled)
|
|
2307
|
+
},
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* Call the face builder and never let a throw escape into slot assembly,
|
|
2313
|
+
* where it would abdicate the entry and hand the cell back to the native
|
|
2314
|
+
* picker. A failed face still renders: the seat reports that it is
|
|
2315
|
+
* unavailable instead of disappearing.
|
|
2316
|
+
* @param sessionId - the Session this seat renders for.
|
|
2317
|
+
* @param build - the face builder.
|
|
2318
|
+
* @returns an injected face, degraded when building failed.
|
|
2319
|
+
*/
|
|
2320
|
+
const safeFace = (sessionId, build) => {
|
|
2321
|
+
try {
|
|
2322
|
+
return build(sessionId)
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
console.error('dsh-model-flatmap: the seat face failed to build', error)
|
|
2325
|
+
return {
|
|
2326
|
+
sessionId,
|
|
2327
|
+
available: true,
|
|
2328
|
+
directory: undefined,
|
|
2329
|
+
resolveDirectory: () => ({ addressed: false, pending: 'seat unavailable' }),
|
|
2330
|
+
load: () => {},
|
|
2331
|
+
select: () => Promise.resolve(undefined),
|
|
2332
|
+
resolveText,
|
|
2333
|
+
teamAvailable: () => false,
|
|
2334
|
+
setTeamMode: () => Promise.resolve(undefined),
|
|
2335
|
+
// A face that failed to build still answers the mark face, so the
|
|
2336
|
+
// component's hook shape never changes and the rows simply stay
|
|
2337
|
+
// unstruck. It shares the module-level empty snapshot so the
|
|
2338
|
+
// identity stays stable across renders.
|
|
2339
|
+
insufficient: {
|
|
2340
|
+
subscribe: () => () => {},
|
|
2341
|
+
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
2342
|
+
dispose: () => {},
|
|
2343
|
+
},
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/**
|
|
2349
|
+
* Register the seat, escalating priority when a stale generation still
|
|
2350
|
+
* holds the previous entry.
|
|
2351
|
+
*/
|
|
2352
|
+
const registerSeat = () => {
|
|
2353
|
+
let lastError
|
|
2354
|
+
for (const priority of [-1, -2, -3, -4, -5]) {
|
|
2355
|
+
try {
|
|
2356
|
+
return slots.register({
|
|
2357
|
+
name: 'conversation.input.model',
|
|
2358
|
+
priority,
|
|
2359
|
+
locale: NS,
|
|
2360
|
+
inject: sessionId => safeFace(sessionId, buildFace),
|
|
2361
|
+
}, Seat)
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
lastError = error
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
console.error('dsh-model-flatmap: the model seat was not registered', lastError)
|
|
2367
|
+
return () => {}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
try {
|
|
2371
|
+
slots.inject('conversation.input.model', registerSeat)
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
console.error('dsh-model-flatmap: slots.inject failed for the model seat', error)
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
})
|