@huai_xin/dsh-model-picker 0.1.2 → 0.2.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/README.md +19 -4
- package/client.js +1287 -838
- package/package.json +2 -2
package/client.js
CHANGED
|
@@ -5,561 +5,472 @@
|
|
|
5
5
|
* This bundle is what the Client module loader serves; it is the only browser
|
|
6
6
|
* entry the loader knows about for this package.
|
|
7
7
|
*/
|
|
8
|
-
//# module: src/core/
|
|
8
|
+
//# module: src/core/rank.js
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Fuzzy ranking for the model search box.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* This is the shipped `rankByName` from `@deepseek-ai/dsh-client-ui-primitives`
|
|
13
|
+
* (the `/` menu's own search), vendored verbatim rather than required: the
|
|
14
|
+
* Client module table only carries `react`, and pulling a host package into the
|
|
15
|
+
* offline unit test would make it impossible to run without DSH installed. The
|
|
16
|
+
* algorithm is self-contained — no imports, no state — so copying it keeps the
|
|
17
|
+
* picker's results identical to the shipped menus while the source tree stays
|
|
18
|
+
* dependency-free.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
+
* What it does, per the original doc comment: the query must be a
|
|
21
|
+
* case-insensitive ORDERED SUBSEQUENCE of the candidate's name (or its `label`
|
|
22
|
+
* when it carries one). Prefix hits rank first, then the strongest alignment
|
|
23
|
+
* score over either key, then the source order of the input.
|
|
24
|
+
*
|
|
25
|
+
* Upstream: `packages/client/ui-primitives/src/rank-by-name.ts`.
|
|
20
26
|
*/
|
|
21
|
-
function createStore(init) {
|
|
22
|
-
let state = init
|
|
23
|
-
const listeners = new Set()
|
|
24
|
-
|
|
25
|
-
const publish = (next) => {
|
|
26
|
-
state = next
|
|
27
|
-
for (const listener of [...listeners]) {
|
|
28
|
-
try {
|
|
29
|
-
listener()
|
|
30
|
-
} catch {
|
|
31
|
-
// One failing subscriber must not stop the others.
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
listeners.add(listener)
|
|
40
|
-
return () => { listeners.delete(listener) }
|
|
41
|
-
},
|
|
42
|
-
set: (next) => publish(next),
|
|
43
|
-
update: (mutate) => {
|
|
44
|
-
const draft = { ...state }
|
|
45
|
-
mutate(draft)
|
|
46
|
-
publish(draft)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
28
|
+
/** Extra weight for name starts and separator boundaries. */
|
|
29
|
+
function boundaryBonus(name, index) {
|
|
30
|
+
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
|
|
49
31
|
}
|
|
50
32
|
|
|
51
33
|
/**
|
|
52
|
-
*
|
|
34
|
+
* Score the strongest ordered-subsequence alignment in O(name × query).
|
|
53
35
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @
|
|
36
|
+
* Boundary and adjacent matches earn weight; skipped and leading characters
|
|
37
|
+
* cost weight.
|
|
38
|
+
*
|
|
39
|
+
* @param name - the lowercased candidate name.
|
|
40
|
+
* @param query - the lowercased query.
|
|
41
|
+
* @returns the score, or undefined when the query is not a subsequence.
|
|
58
42
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
])
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* One Host-generation catalogue shared by every session.
|
|
92
|
-
*
|
|
93
|
-
* Mirrors the shipped `ModelCatalogDirectory`: a single value, one in-flight
|
|
94
|
-
* request shared by every reader, a generation counter that discards responses
|
|
95
|
-
* from a superseded generation, and a `reasoning` map that remembers each
|
|
96
|
-
* model's effort metadata *after* the model leaves the catalogue — so a level
|
|
97
|
-
* still shows its name while its model is gone.
|
|
98
|
-
*
|
|
99
|
-
* @param resolveRemote - returns the client remote face carrying `session.modelCatalog`.
|
|
100
|
-
* A THUNK, not the face itself: this catalogue is built in `apply`, where the
|
|
101
|
-
* `remote` service is often not visible yet (it arrives later in the client boot
|
|
102
|
-
* sequence). Capturing the value here froze `undefined` and every load then
|
|
103
|
-
* reported "modelCatalog is unavailable".
|
|
104
|
-
* @param onDiagnostic - optional sink for load-stage notes.
|
|
105
|
-
* @returns the catalogue controller.
|
|
106
|
-
*/
|
|
107
|
-
function createCatalog(resolveRemote, onDiagnostic) {
|
|
108
|
-
const store = createStore({ value: null, status: 'idle', error: null })
|
|
109
|
-
const reasoning = new Map()
|
|
110
|
-
// Bumped by every invalidation. A response that resolves under an older
|
|
111
|
-
// generation is dropped instead of publishing a catalogue the Host has
|
|
112
|
-
// already superseded.
|
|
113
|
-
let generation = 0
|
|
114
|
-
let inflight
|
|
115
|
-
|
|
116
|
-
const note = (message) => {
|
|
117
|
-
try { onDiagnostic?.(message) } catch { /* diagnostics never break the load */ }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** The remote face as it is right now, or undefined while the service is absent. */
|
|
121
|
-
const currentRemote = () => {
|
|
122
|
-
try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const reasoningFor = (selection) => selection === null || selection === undefined
|
|
126
|
-
? undefined
|
|
127
|
-
: reasoning.get(JSON.stringify([selection.provider, selection.model]))
|
|
128
|
-
|
|
129
|
-
const load = () => {
|
|
130
|
-
const state = store.getSnapshot()
|
|
131
|
-
if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
|
|
132
|
-
if (inflight !== undefined) return inflight
|
|
133
|
-
const remote = currentRemote()
|
|
134
|
-
const run = remote?.session?.modelCatalog
|
|
135
|
-
if (typeof run !== 'function') {
|
|
136
|
-
note('modelCatalog 不可用:remote 尚未就绪')
|
|
137
|
-
store.set({ value: state.value, status: 'error', error: 'modelCatalog is unavailable' })
|
|
138
|
-
return Promise.resolve(undefined)
|
|
139
|
-
}
|
|
140
|
-
const at = generation
|
|
141
|
-
// Keep the previous value visible while reloading: an invalidation is not an
|
|
142
|
-
// error, and blanking the list would flash on every provider edit.
|
|
143
|
-
store.update((draft) => { draft.status = 'loading'; draft.error = null })
|
|
144
|
-
const operation = Promise.resolve(run.call(remote.session)).then((result) => {
|
|
145
|
-
if (result?.ok !== true) {
|
|
146
|
-
note(`modelCatalog 失败:${result?.error?.code ?? '?'} ${result?.error?.message ?? ''}`)
|
|
147
|
-
if (at === generation) {
|
|
148
|
-
store.set({
|
|
149
|
-
value: store.getSnapshot().value,
|
|
150
|
-
status: 'error',
|
|
151
|
-
error: result?.error?.message ?? 'failed'
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
return undefined
|
|
155
|
-
}
|
|
156
|
-
const groups = Array.isArray(result.value?.groups) ? result.value.groups : []
|
|
157
|
-
note(`modelCatalog 成功:${groups.length} 组,gen ${at}/${generation}`)
|
|
158
|
-
if (at === generation) {
|
|
159
|
-
for (const group of groups) {
|
|
160
|
-
for (const model of group.models) {
|
|
161
|
-
reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
store.set({ value: result.value, status: 'ready', error: null })
|
|
165
|
-
} else {
|
|
166
|
-
// The result was dropped: another load superseded it. Say so, because a
|
|
167
|
-
// silently discarded success is what leaves the store on `loading`.
|
|
168
|
-
note(`modelCatalog 结果被丢弃(gen ${at} != ${generation})`)
|
|
169
|
-
}
|
|
170
|
-
return result.value
|
|
171
|
-
}).catch((error) => {
|
|
172
|
-
note(`modelCatalog 抛错:${error instanceof Error ? error.message : String(error)}`)
|
|
173
|
-
if (at === generation) {
|
|
174
|
-
store.set({
|
|
175
|
-
value: store.getSnapshot().value,
|
|
176
|
-
status: 'error',
|
|
177
|
-
error: error instanceof Error ? error.message : String(error)
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
return undefined
|
|
181
|
-
}).finally(() => {
|
|
182
|
-
if (at === generation && inflight === operation) inflight = undefined
|
|
183
|
-
})
|
|
184
|
-
inflight = operation
|
|
185
|
-
return operation
|
|
186
|
-
}
|
|
187
|
-
const invalidate = (clear) => {
|
|
188
|
-
generation += 1
|
|
189
|
-
inflight = undefined
|
|
190
|
-
store.set({
|
|
191
|
-
value: clear === true ? null : store.getSnapshot().value,
|
|
192
|
-
status: 'idle',
|
|
193
|
-
error: null
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Re-read on the next explicit load, keeping the stale value on screen. */
|
|
198
|
-
const refresh = () => {
|
|
199
|
-
note(`refresh 被调用(gen ${generation} → ${generation + 1})`)
|
|
200
|
-
invalidate(false)
|
|
201
|
-
load()
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Drop everything: the value belonged to a Host generation that is gone. */
|
|
205
|
-
const resetGeneration = () => { reasoning.clear(); invalidate(true); load() }
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Subscribe the invalidation events.
|
|
209
|
-
*
|
|
210
|
-
* Call this from the plugin's `apply` body, not from a scope or slot callback.
|
|
211
|
-
* The gateway registers a forwarded-event listener in the CALLING fiber's
|
|
212
|
-
* private event domain, and only the plugin's own `apply` fiber is in the
|
|
213
|
-
* domain the dispatcher publishes to — a subscription installed from a
|
|
214
|
-
* `ctx.inject` callback is accepted (it returns a disposer) but never fires.
|
|
215
|
-
*
|
|
216
|
-
* @param target - the client remote carrying `$on`.
|
|
217
|
-
* @param onEvent - optional observer called with each delivered event name.
|
|
218
|
-
* @returns a disposer removing every subscription.
|
|
219
|
-
*/
|
|
220
|
-
const attach = (target, onEvent) => {
|
|
221
|
-
const offs = []
|
|
222
|
-
const failed = []
|
|
223
|
-
for (const event of INVALIDATION_EVENTS) {
|
|
224
|
-
try {
|
|
225
|
-
if (typeof target?.$on !== 'function') {
|
|
226
|
-
failed.push(`${event}: 目标没有 $on`)
|
|
227
|
-
continue
|
|
228
|
-
}
|
|
229
|
-
const off = target.$on(event, () => {
|
|
230
|
-
onEvent?.(event)
|
|
231
|
-
refresh()
|
|
232
|
-
})
|
|
233
|
-
if (typeof off === 'function') offs.push(off)
|
|
234
|
-
else failed.push(`${event}: 返回的不是 disposer`)
|
|
235
|
-
} catch (error) {
|
|
236
|
-
failed.push(`${event}: ${error instanceof Error ? error.message : String(error)}`)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
note(`订阅结果:成功 ${offs.length}/${INVALIDATION_EVENTS.length}${failed.length > 0 ? `;问题:${failed.join(' | ')}` : ''}`)
|
|
240
|
-
return () => { for (const off of offs) off() }
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Whether this target can carry event subscriptions at all. */
|
|
244
|
-
const canAttach = (target) => typeof target?.$on === 'function'
|
|
245
|
-
|
|
246
|
-
return { store, reasoningFor, load, refresh, resetGeneration, attach, canAttach }
|
|
43
|
+
function alignmentScore(name, query) {
|
|
44
|
+
if (query.length > name.length) return undefined
|
|
45
|
+
const noMatch = Number.NEGATIVE_INFINITY
|
|
46
|
+
let previous = Array(name.length).fill(noMatch)
|
|
47
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
48
|
+
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
|
|
49
|
+
}
|
|
50
|
+
for (let queryIndex = 1; queryIndex < query.length; queryIndex += 1) {
|
|
51
|
+
const current = Array(name.length).fill(noMatch)
|
|
52
|
+
let left = noMatch
|
|
53
|
+
let leftLeft = noMatch
|
|
54
|
+
let bestGapped = noMatch
|
|
55
|
+
for (const [index, prior] of previous.entries()) {
|
|
56
|
+
if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2)
|
|
57
|
+
if (name.charAt(index) === query.charAt(queryIndex)) {
|
|
58
|
+
const bonus = 1 + boundaryBonus(name, index)
|
|
59
|
+
let score = noMatch
|
|
60
|
+
if (left !== noMatch) score = left + bonus + 4
|
|
61
|
+
if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index)
|
|
62
|
+
current[index] = score
|
|
63
|
+
}
|
|
64
|
+
leftLeft = left
|
|
65
|
+
left = prior
|
|
66
|
+
}
|
|
67
|
+
previous = current
|
|
68
|
+
}
|
|
69
|
+
let best = noMatch
|
|
70
|
+
for (const score of previous) best = Math.max(best, score)
|
|
71
|
+
return best === noMatch ? undefined : best
|
|
247
72
|
}
|
|
248
73
|
|
|
249
|
-
//# module: src/core/selection.js
|
|
250
74
|
/**
|
|
251
|
-
*
|
|
75
|
+
* Rank named items by a search query.
|
|
252
76
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* @param projection - a projection frame, a selection, or nothing.
|
|
259
|
-
* @returns `{ provider, model, reasoningEffort }`, or null when unreadable.
|
|
77
|
+
* @param items - candidates in source order; an item's optional `label` is a
|
|
78
|
+
* second search key beside its `name`.
|
|
79
|
+
* @param rawQuery - the text typed into the search box, matched case-insensitively.
|
|
80
|
+
* @returns the matching items: prefix hits first, then by alignment score, then
|
|
81
|
+
* in source order. The input list itself for an empty query.
|
|
260
82
|
*/
|
|
261
|
-
function
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
83
|
+
function rankByName(items, rawQuery) {
|
|
84
|
+
const query = String(rawQuery ?? '').toLowerCase()
|
|
85
|
+
if (query === '') return items
|
|
86
|
+
const ranked = []
|
|
87
|
+
items.forEach((item, index) => {
|
|
88
|
+
const name = String(item?.name ?? item?.id ?? '')
|
|
89
|
+
const keys = item?.label === undefined ? [name] : [name, String(item.label)]
|
|
90
|
+
let prefix = false
|
|
91
|
+
let score
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const lower = key.toLowerCase()
|
|
94
|
+
const keyScore = alignmentScore(lower, query)
|
|
95
|
+
if (keyScore === undefined) continue
|
|
96
|
+
prefix ||= lower.startsWith(query)
|
|
97
|
+
score = score === undefined ? keyScore : Math.max(score, keyScore)
|
|
98
|
+
}
|
|
99
|
+
if (score !== undefined) ranked.push({ item, index, prefix, score })
|
|
100
|
+
})
|
|
101
|
+
ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) ||
|
|
102
|
+
right.score - left.score ||
|
|
103
|
+
left.index - right.index)
|
|
104
|
+
return ranked.map((match) => match.item)
|
|
268
105
|
}
|
|
269
106
|
|
|
107
|
+
//# module: src/ui/Chevron.js
|
|
270
108
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* An addressed subagent session cannot: the Host rejects the RPC. The shipped
|
|
274
|
-
* picker renders nothing in that case rather than offering a control whose every
|
|
275
|
-
* click fails. `subagentAddress` is the same probe it uses; a client generation
|
|
276
|
-
* without it counts as available, so an older build keeps showing the control
|
|
277
|
-
* instead of silently losing it.
|
|
109
|
+
* The chevron that turns when its panel is open.
|
|
278
110
|
*
|
|
279
|
-
* @param
|
|
280
|
-
* @
|
|
281
|
-
* @returns whether the model control should be offered.
|
|
111
|
+
* @param props - `{ h, open }`: the createElement function and the open flag.
|
|
112
|
+
* @returns the svg element.
|
|
282
113
|
*/
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
114
|
+
function Chevron({ h, open }) {
|
|
115
|
+
return h('svg', {
|
|
116
|
+
className: `dmp-chevron${open ? ' dmp-chevron-open' : ''}`,
|
|
117
|
+
viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true
|
|
118
|
+
}, h('path', {
|
|
119
|
+
d: 'M4 6.5L8 10.5L12 6.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6,
|
|
120
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round'
|
|
121
|
+
}))
|
|
290
122
|
}
|
|
291
123
|
|
|
292
|
-
//# module: src/
|
|
293
|
-
|
|
294
|
-
|
|
124
|
+
//# module: src/ui/anchor.js
|
|
295
125
|
/**
|
|
296
|
-
*
|
|
126
|
+
* Anchor a popover to the centre of its trigger, clamped to the viewport.
|
|
297
127
|
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* the in-flight state (so a slow switch shows nothing) and the failure list (so a
|
|
303
|
-
* broken provider group is silently missing).
|
|
128
|
+
* Both popovers in this picker open ABOVE their button, and both are wider than
|
|
129
|
+
* the button they belong to. Anchoring by a fixed side (`left: 0` or `right: 0`)
|
|
130
|
+
* makes the panel drift away from the control the user just pressed, and near
|
|
131
|
+
* either screen edge it runs off the window entirely.
|
|
304
132
|
*
|
|
305
|
-
* The
|
|
306
|
-
*
|
|
133
|
+
* The hook measures the trigger, centres the panel on it, then shifts the result
|
|
134
|
+
* so the panel stays inside the viewport with a small margin. It returns:
|
|
307
135
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
136
|
+
* - `ref` — attach to the trigger element,
|
|
137
|
+
* - `panelRef`— attach to the panel element (its width depends on content),
|
|
138
|
+
* - `style` — spread onto the panel: centring transform, clamp offset, and
|
|
139
|
+
* `visibility` gated on the first measurement so the panel never
|
|
140
|
+
* paints at the wrong place for a frame.
|
|
141
|
+
*
|
|
142
|
+
* @param React - the React runtime.
|
|
143
|
+
* @param options - `{ open, fallbackWidth, deps }`.
|
|
144
|
+
* @returns `{ ref, panelRef, style }`.
|
|
313
145
|
*/
|
|
314
|
-
function
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
routable: null,
|
|
318
|
-
groups: [],
|
|
319
|
-
failures: [],
|
|
320
|
-
status: 'idle',
|
|
321
|
-
pending: null,
|
|
322
|
-
error: null
|
|
323
|
-
})
|
|
324
|
-
// Latest selection wins: an older response must never overwrite a newer one.
|
|
325
|
-
let generation = 0
|
|
326
|
-
let disposed = false
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Recompute the effective selection from the two inputs that define it.
|
|
330
|
-
*
|
|
331
|
-
* `intended` is the session's explicit choice, else the catalogue default —
|
|
332
|
-
* exactly the shipped precedence. `routable` says whether the Host would still
|
|
333
|
-
* accept it, which is what lets the trigger distinguish "this model" from "a
|
|
334
|
-
* model that no longer exists".
|
|
335
|
-
*/
|
|
336
|
-
const sync = () => {
|
|
337
|
-
if (disposed) return
|
|
338
|
-
const catalogState = catalog.store.getSnapshot()
|
|
339
|
-
const projected = readSelection(projectedFace?.getSnapshot?.())
|
|
340
|
-
const intended = projected ?? readSelection(catalogState.value?.default)
|
|
341
|
-
const reasoning = catalog.reasoningFor(intended)
|
|
342
|
-
const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort
|
|
343
|
-
const retainedEffort = effort === undefined
|
|
344
|
-
? undefined
|
|
345
|
-
: reasoning?.efforts?.find((level) => level.id === effort)?.name ?? effort
|
|
346
|
-
const ready = catalogState.status === 'ready' && catalogState.value !== null
|
|
347
|
-
if (!ready || intended === null) {
|
|
348
|
-
store.set({
|
|
349
|
-
current: catalogState.value === null ? null : store.getSnapshot().current,
|
|
350
|
-
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
351
|
-
routable: null,
|
|
352
|
-
groups: catalogState.value?.groups ?? [],
|
|
353
|
-
failures: catalogState.value?.failures ?? [],
|
|
354
|
-
status: catalogState.status === 'error' ? 'error' : 'loading',
|
|
355
|
-
pending: store.getSnapshot().pending,
|
|
356
|
-
error: catalogState.error
|
|
357
|
-
})
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
const routable = (catalogState.value.groups ?? []).some((group) =>
|
|
361
|
-
group.id === intended.provider && (group.models ?? []).some((model) => model.id === intended.model))
|
|
362
|
-
store.set({
|
|
363
|
-
current: intended,
|
|
364
|
-
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
365
|
-
routable,
|
|
366
|
-
groups: catalogState.value.groups,
|
|
367
|
-
failures: catalogState.value.failures ?? [],
|
|
368
|
-
status: store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready',
|
|
369
|
-
pending: store.getSnapshot().pending,
|
|
370
|
-
error: null
|
|
371
|
-
})
|
|
372
|
-
}
|
|
146
|
+
function createAnchoredPanel(React, options) {
|
|
147
|
+
const { useState, useEffect, useRef } = React
|
|
148
|
+
const { open, fallbackWidth = 280, margin = 8, deps = [] } = options
|
|
373
149
|
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
150
|
+
const [anchor, setAnchor] = useState({ offset: 0, ready: false })
|
|
151
|
+
const triggerRef = useRef(null)
|
|
152
|
+
const panelRef = useRef(null)
|
|
377
153
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
* the trigger can show which row is settling. A response that arrives after a
|
|
383
|
-
* newer selection (or after disposal) is dropped rather than written, which is
|
|
384
|
-
* the bug a bare `busy` flag cannot avoid: two fast clicks would otherwise let
|
|
385
|
-
* the first reply overwrite the second choice.
|
|
386
|
-
*
|
|
387
|
-
* @param selection - provider, model, and optional reasoningEffort.
|
|
388
|
-
* @returns the Host result, or a synthesized failure.
|
|
389
|
-
*/
|
|
390
|
-
const select = async (selection) => {
|
|
391
|
-
// The adapter's default effort is part of the selection the shipped picker
|
|
392
|
-
// submits, so a model switch also settles the level instead of leaving the
|
|
393
|
-
// Host to guess on the next call.
|
|
394
|
-
const nextEffort = selection.reasoningEffort ?? catalog.reasoningFor(selection)?.defaultEffort
|
|
395
|
-
const next = { ...selection, ...nextEffort === undefined ? {} : { reasoningEffort: nextEffort } }
|
|
396
|
-
const generationAt = ++generation
|
|
397
|
-
store.update((draft) => {
|
|
398
|
-
draft.status = 'selecting'
|
|
399
|
-
draft.pending = next
|
|
400
|
-
draft.error = null
|
|
401
|
-
})
|
|
402
|
-
const run = remote?.session?.selectModel
|
|
403
|
-
if (typeof run !== 'function') {
|
|
404
|
-
store.update((draft) => {
|
|
405
|
-
draft.status = 'error'
|
|
406
|
-
draft.pending = null
|
|
407
|
-
draft.error = 'selectModel is unavailable'
|
|
408
|
-
})
|
|
409
|
-
return { ok: false, error: { code: 'unavailable', message: 'selectModel is unavailable' } }
|
|
410
|
-
}
|
|
411
|
-
let result
|
|
412
|
-
try {
|
|
413
|
-
result = await run.call(remote.session, {
|
|
414
|
-
sessionId,
|
|
415
|
-
provider: next.provider,
|
|
416
|
-
model: next.model,
|
|
417
|
-
...next.reasoningEffort === undefined ? {} : { reasoningEffort: next.reasoningEffort }
|
|
418
|
-
})
|
|
419
|
-
} catch (error) {
|
|
420
|
-
result = {
|
|
421
|
-
ok: false,
|
|
422
|
-
error: { code: 'threw', message: error instanceof Error ? error.message : String(error) }
|
|
423
|
-
}
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!open) {
|
|
156
|
+
setAnchor({ offset: 0, ready: false })
|
|
157
|
+
return undefined
|
|
424
158
|
}
|
|
425
|
-
|
|
426
|
-
|
|
159
|
+
const measure = () => {
|
|
160
|
+
const trigger = triggerRef.current
|
|
161
|
+
if (trigger === null || typeof trigger.getBoundingClientRect !== 'function') return
|
|
162
|
+
const rect = trigger.getBoundingClientRect()
|
|
163
|
+
const viewport = (typeof window !== 'undefined' && window.innerWidth)
|
|
164
|
+
|| (typeof document !== 'undefined' && document.documentElement?.clientWidth)
|
|
165
|
+
|| 0
|
|
166
|
+
if (viewport === 0) return
|
|
167
|
+
|
|
168
|
+
// Read the panel's real width: it depends on content (a five-level slider is
|
|
169
|
+
// wider than a three-level one). `visibility: hidden` does not affect
|
|
170
|
+
// `getBoundingClientRect`, so the width is available before the first paint.
|
|
171
|
+
// The ref is attached in the same commit the panel is rendered in, so it is
|
|
172
|
+
// set by the time this effect runs.
|
|
173
|
+
const panel = panelRef.current
|
|
174
|
+
const measured = panel !== null && panel !== undefined
|
|
175
|
+
&& typeof panel.getBoundingClientRect === 'function'
|
|
176
|
+
? panel.getBoundingClientRect().width
|
|
177
|
+
: 0
|
|
178
|
+
const width = measured > 0 ? measured : Math.min(fallbackWidth, viewport - 32)
|
|
179
|
+
|
|
180
|
+
const centre = rect.left + rect.width / 2
|
|
181
|
+
const centred = centre - width / 2
|
|
182
|
+
const maxLeft = viewport - width - margin
|
|
183
|
+
const clamped = Math.max(margin, Math.min(centred, Math.max(margin, maxLeft)))
|
|
184
|
+
setAnchor({ offset: clamped - centred, ready: true })
|
|
427
185
|
}
|
|
428
|
-
if (result?.ok !== true) {
|
|
429
|
-
store.update((draft) => {
|
|
430
|
-
draft.status = 'error'
|
|
431
|
-
draft.pending = null
|
|
432
|
-
draft.error = `${result?.error?.code ?? 'error'}: ${result?.error?.message ?? 'failed'}`
|
|
433
|
-
})
|
|
434
|
-
return result
|
|
435
|
-
}
|
|
436
|
-
store.update((draft) => {
|
|
437
|
-
draft.status = 'ready'
|
|
438
|
-
draft.pending = null
|
|
439
|
-
draft.error = null
|
|
440
|
-
})
|
|
441
|
-
/**
|
|
442
|
-
* Do NOT re-project here.
|
|
443
|
-
*
|
|
444
|
-
* `sync()` recomputes `current` from the session's projected selection. The
|
|
445
|
-
* Host answers `selectModel` before it publishes that projection, so calling
|
|
446
|
-
* `sync()` at this point reads the OLD selection and writes it straight back
|
|
447
|
-
* over the one just chosen — the picker then looks like the click did
|
|
448
|
-
* nothing. The projection's own subscription calls `sync()` when the Host
|
|
449
|
-
* actually publishes, which is the only moment the new value is real.
|
|
450
|
-
*
|
|
451
|
-
* Until that push arrives, `current` is the selection the Host just
|
|
452
|
-
* acknowledged. `selected` in the answer is authoritative when present;
|
|
453
|
-
* otherwise the triple we sent is what the Host accepted.
|
|
454
|
-
*/
|
|
455
|
-
const settled = readSelection(result?.value?.selected) ?? next
|
|
456
|
-
store.update((draft) => {
|
|
457
|
-
draft.current = settled
|
|
458
|
-
draft.routable = true
|
|
459
|
-
})
|
|
460
|
-
return { ok: true, value: undefined }
|
|
461
|
-
}
|
|
462
186
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
187
|
+
measure()
|
|
188
|
+
// Second pass after layout: the first can see the min-width instead of the
|
|
189
|
+
// real width when the content is wider than the minimum.
|
|
190
|
+
const raf = typeof requestAnimationFrame === 'function'
|
|
191
|
+
? requestAnimationFrame(measure)
|
|
192
|
+
: undefined
|
|
193
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
194
|
+
window.addEventListener('resize', measure)
|
|
195
|
+
return () => {
|
|
196
|
+
window.removeEventListener('resize', measure)
|
|
197
|
+
if (raf !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return undefined
|
|
201
|
+
// `deps` carries content-dependent values (level count, model count) that
|
|
202
|
+
// change the panel's width without changing `open`.
|
|
203
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
+
}, [open, ...deps])
|
|
474
205
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
206
|
+
return {
|
|
207
|
+
ref: triggerRef,
|
|
208
|
+
panelRef,
|
|
209
|
+
style: {
|
|
210
|
+
left: '50%',
|
|
211
|
+
right: 'auto',
|
|
212
|
+
transform: `translateX(calc(-50% + ${anchor.offset}px))`,
|
|
213
|
+
visibility: anchor.ready ? 'visible' : 'hidden'
|
|
214
|
+
}
|
|
480
215
|
}
|
|
216
|
+
}
|
|
481
217
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
218
|
+
//# module: src/ui/caption-glyphs.js
|
|
219
|
+
/**
|
|
220
|
+
* The panel heading as vector art.
|
|
221
|
+
*
|
|
222
|
+
* Why a path and not text: Montserrat has no CJK glyphs, so the four
|
|
223
|
+
* characters cannot get the brand face from a font stack. These outlines were
|
|
224
|
+
* taken from Noto Sans SC — the closest geometric match available — with a
|
|
225
|
+
* rounded stroke applied so the joins read like Montserrat's.
|
|
226
|
+
*
|
|
227
|
+
* Filled with `currentColor`, so the mark follows the theme exactly as the text
|
|
228
|
+
* beside it does, and holds its shape at any zoom or DPI.
|
|
229
|
+
*/
|
|
230
|
+
const CAPTION_VIEWBOX = '0 0 3891 910'
|
|
488
231
|
|
|
489
|
-
|
|
490
|
-
|
|
232
|
+
/** One entry per character: horizontal offset, baseline, and outline. */
|
|
233
|
+
const CAPTION_GLYPHS = [
|
|
234
|
+
{ x: -61, y: 833, d: 'M293 248 L323 248 L323 15 Q323 -5 350 -18 L398 -18 Q409 -18 474 -18 Q553 -18 623 -18 L638 -18 Q666 -18 694 2 Q706 64 708 127 Q713 124 723 118 Q733 114 737 113 Q734 48 718 -22 Q679 -48 639 -48 Q633 -48 585 -48 Q520 -48 454 -48 Q406 -48 401 -48 Q360 -48 312 -38 Q293 -11 293 16 Z M379 294 L399 312 Q439 292 523 240 Q597 187 621 164 L601 143 Q578 165 504 219 Q419 273 379 294 Z M754 238 L780 249 Q811 211 872 120 Q921 33 933 -1 L905 -15 Q892 19 845 107 Q785 199 754 238 Z M175 233 L200 225 Q189 186 156 94 Q113 7 87 -25 L61 -13 Q88 19 131 105 Q164 196 175 233 Z M183 557 L183 386 L808 386 L808 557 Z M183 755 L183 586 L808 586 L808 755 Z M154 784 L837 784 L837 357 L154 357 Z M483 773 L513 773 L513 372 L483 372 Z' },
|
|
235
|
+
{ x: 939, y: 833, d: 'M748 205 L778 205 Q778 205 777 198 Q777 189 776 186 Q761 78 730 -23 L708 -42 Q695 -52 668 -58 L645 -59 Q622 -59 521 -56 L463 -52 Q462 -45 458 -29 L453 -22 Q493 -26 569 -30 Q626 -31 638 -31 Q658 -31 679 -28 L686 -23 Q706 -8 733 92 L748 200 Z M313 205 L759 205 L759 176 L306 176 Z M368 361 L845 361 L845 331 L354 331 Z M73 519 L933 519 L933 489 L73 489 Z M166 698 L705 698 L705 669 L166 669 Z M442 831 L472 831 L472 505 L442 505 Z M857 778 L882 763 Q806 670 620 508 Q410 368 185 252 L68 206 Q66 210 58 219 Q49 229 44 232 Q163 276 392 390 Q602 529 784 689 L857 778 Z M360 361 L392 361 Q375 314 333 211 L317 176 L285 176 Q304 214 344 317 L360 361 Z' },
|
|
236
|
+
{ x: 1939, y: 833, d: 'M378 -13 Q440 -9 608 3 Q799 17 896 25 L895 -3 Q803 -10 618 -25 Q451 -38 385 -43 Z M783 134 L808 148 Q838 116 894 40 Q940 -33 952 -62 L926 -77 Q914 -48 869 26 Q812 102 783 134 Z M456 406 L456 220 L863 220 L863 406 Z M427 435 L892 435 L892 190 L427 190 Z M477 747 L477 578 L837 578 L837 747 Z M448 776 L867 776 L867 549 L448 549 Z M642 560 L672 560 L672 -14 L642 -16 Z M84 324 L323 324 L323 294 L84 294 Z M315 324 L345 324 Q345 324 344 317 Q344 307 343 304 Q336 186 319 43 Q299 -29 285 -43 Q276 -53 252 -62 L233 -62 Q217 -63 157 -61 L124 -58 Q123 -52 120 -35 L115 -29 Q153 -32 216 -34 L228 -34 Q241 -34 256 -31 L262 -24 Q273 -13 291 59 Q308 201 315 317 Z M97 551 L127 551 Q123 510 110 415 Q97 327 91 295 L61 295 Q68 328 82 417 Q93 511 97 551 Z M110 551 L329 551 L329 747 L64 747 L64 776 L358 776 L358 522 L110 522 Z' },
|
|
237
|
+
{ x: 2939, y: 833, d: 'M200 551 L928 551 L928 522 L200 522 Z M220 259 L816 259 L816 229 L220 229 Z M386 659 L416 659 L416 376 L720 376 L720 659 L749 659 L749 347 L386 347 Z M807 259 L814 259 L820 261 L839 248 Q797 173 657 65 Q481 -8 286 -53 L187 -65 Q186 -60 178 -44 L174 -38 Q269 -28 461 14 Q633 83 768 184 L807 252 Z M371 228 Q422 151 598 39 Q825 -31 952 -45 Q949 -48 942 -58 Q936 -67 934 -71 Q808 -54 579 19 Q398 135 345 216 Z M155 718 L941 718 L941 688 L155 688 Z M141 718 L171 718 L171 441 Q171 385 164 254 Q144 117 106 -15 L74 -72 Q72 -69 62 -63 Q53 -57 49 -56 Q81 0 117 128 Q136 260 141 386 L141 441 Z M484 824 L512 833 Q531 805 570 737 L579 714 L551 702 Q541 726 504 796 L484 824 Z' },
|
|
238
|
+
]
|
|
491
239
|
|
|
492
|
-
//# module: src/
|
|
240
|
+
//# module: src/ui/CaptionMark.js
|
|
493
241
|
|
|
494
242
|
/**
|
|
495
|
-
* The
|
|
496
|
-
*
|
|
243
|
+
* The panel heading, drawn as vector art.
|
|
244
|
+
*
|
|
245
|
+
* Montserrat has no CJK glyphs, so a font stack cannot give these four characters
|
|
246
|
+
* the host's brand face — they always fall back to the platform UI font. This
|
|
247
|
+
* renders them from outlines instead, so the heading keeps one shape everywhere and
|
|
248
|
+
* can be styled like a mark rather than a run of text.
|
|
497
249
|
*
|
|
498
|
-
* The
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* build), this plugin provides its own under the same name so the rest of the
|
|
502
|
-
* code has one contract either way.
|
|
250
|
+
* The fill is `currentColor`: the mark inherits whatever colour the surrounding
|
|
251
|
+
* element already resolved from the theme tokens, so light and dark follow
|
|
252
|
+
* automatically and no JS has to read the theme.
|
|
503
253
|
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
* @returns the service object with `directoryFor`.
|
|
254
|
+
* The text is still exposed to assistive tech through `aria-label` and a `<title>`,
|
|
255
|
+
* because the glyphs themselves carry no semantics.
|
|
256
|
+
*
|
|
257
|
+
* @param props - `{ h, label, className, height }`.
|
|
258
|
+
* @returns the SVG element tree.
|
|
510
259
|
*/
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
const currentRemote = () => {
|
|
514
|
-
try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
|
|
515
|
-
}
|
|
260
|
+
function createCaptionMark(React) {
|
|
261
|
+
const h = React.createElement
|
|
516
262
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
263
|
+
return function CaptionMark({ label, className = 'dmp-caption-mark', height = 19 }) {
|
|
264
|
+
// The outlines use font units with Y pointing up, so each glyph is flipped on
|
|
265
|
+
// its own baseline. Doing it per glyph keeps the path data verbatim.
|
|
266
|
+
const glyphs = CAPTION_GLYPHS.map((glyph, index) => h('path', {
|
|
267
|
+
key: index,
|
|
268
|
+
transform: `translate(${glyph.x} ${glyph.y}) scale(1 -1)`,
|
|
269
|
+
d: glyph.d
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
return h('svg', {
|
|
273
|
+
className,
|
|
274
|
+
viewBox: CAPTION_VIEWBOX,
|
|
275
|
+
height,
|
|
276
|
+
// Width follows the viewBox ratio, so `height` alone is enough to size it.
|
|
277
|
+
role: 'img',
|
|
278
|
+
'aria-label': label,
|
|
279
|
+
focusable: 'false'
|
|
280
|
+
},
|
|
281
|
+
h('title', null, label),
|
|
282
|
+
h('g', {
|
|
283
|
+
// A rounded stroke widens the strokes and softens every join, which is what
|
|
284
|
+
// brings the geometric Noto skeleton closer to Montserrat's weight.
|
|
285
|
+
fill: 'currentColor',
|
|
286
|
+
stroke: 'currentColor',
|
|
287
|
+
strokeWidth: 76,
|
|
288
|
+
strokeLinejoin: 'round',
|
|
289
|
+
strokeLinecap: 'round'
|
|
290
|
+
}, glyphs))
|
|
543
291
|
}
|
|
544
|
-
|
|
545
|
-
return { directoryFor, directories }
|
|
546
292
|
}
|
|
547
293
|
|
|
548
|
-
//# module: src/ui/
|
|
294
|
+
//# module: src/ui/EffortSlider.js
|
|
549
295
|
/**
|
|
550
|
-
*
|
|
296
|
+
* A discrete reasoning slider over the model's declared levels.
|
|
551
297
|
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
298
|
+
* The visuals inherit the Host theme. A real range input owns pointer and
|
|
299
|
+
* keyboard interaction; the thin track and thumb are its painted layer. Labels
|
|
300
|
+
* remain clickable jump targets.
|
|
301
|
+
*
|
|
302
|
+
* ## Why this component keeps its own position
|
|
303
|
+
*
|
|
304
|
+
* Every stop submits a selection to the Host, and the Host answers a round trip
|
|
305
|
+
* later. Two things follow from that, and both were visible as "the thumb does
|
|
306
|
+
* not follow the mouse":
|
|
307
|
+
*
|
|
308
|
+
* - A CONTROLLED input (`value={index}`) gets the old index written back on
|
|
309
|
+
* every parent render, so the thumb snaps backwards mid-drag.
|
|
310
|
+
* - Re-submitting on every pixel of travel queues a request per stop, and the
|
|
311
|
+
* directory flips to `selecting` in between — which used to disable the input
|
|
312
|
+
* and kill the gesture after the first stop.
|
|
313
|
+
*
|
|
314
|
+
* So the input is uncontrolled and this component tracks the live stop itself.
|
|
315
|
+
* `onInput` updates local state only (instant, no Host involved); the commit to
|
|
316
|
+
* the Host happens once, on `onChange` — the event browsers fire when the pointer
|
|
317
|
+
* is released. A drag is therefore always smooth, and one gesture is one request.
|
|
318
|
+
*
|
|
319
|
+
* The one thing the uncontrolled input cannot do on its own is follow a level
|
|
320
|
+
* chosen WITHOUT a drag — clicking a label under the rail, or a level arriving
|
|
321
|
+
* from `/model`. Those change `activeId` while the input's internal value still
|
|
322
|
+
* points at the old stop, so the next key press would start from there. A ref
|
|
323
|
+
* writes the value back whenever no drag is in progress; it is never written
|
|
324
|
+
* during a gesture, which is exactly what keeps the thumb glued to the pointer.
|
|
325
|
+
*
|
|
326
|
+
* @param props - `{ h, efforts, activeId, disabled, onChange, t, caption }`.
|
|
327
|
+
* @returns the slider element tree.
|
|
554
328
|
*/
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
329
|
+
|
|
330
|
+
function createEffortSlider(React) {
|
|
331
|
+
const h = React.createElement
|
|
332
|
+
const CaptionMark = createCaptionMark(React)
|
|
333
|
+
const { useState, useEffect, useRef } = React
|
|
334
|
+
|
|
335
|
+
return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
|
|
336
|
+
const count = efforts.length
|
|
337
|
+
const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The stop under the pointer, which is ahead of `activeId` while dragging.
|
|
341
|
+
*
|
|
342
|
+
* `null` means "no drag in progress, follow the prop".
|
|
343
|
+
*/
|
|
344
|
+
const [dragging, setDragging] = useState(null)
|
|
345
|
+
const index = dragging ?? incoming
|
|
346
|
+
const active = efforts[index]
|
|
347
|
+
const activeName = active?.name ?? active?.id ?? ''
|
|
348
|
+
const inputRef = useRef(null)
|
|
349
|
+
|
|
350
|
+
// A prop change from outside (another entry changed the level, the session
|
|
351
|
+
// switched model) wins over a stale drag value.
|
|
352
|
+
useEffect(() => { setDragging(null) }, [activeId, efforts.length])
|
|
353
|
+
|
|
354
|
+
// The DOM value follows the level unless a gesture owns it. Writing during a
|
|
355
|
+
// drag would fight the pointer, so `dragging` gates it.
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (dragging !== null) return
|
|
358
|
+
const input = inputRef.current
|
|
359
|
+
if (input !== null && typeof input === 'object' && 'value' in input) {
|
|
360
|
+
input.value = String(incoming)
|
|
361
|
+
}
|
|
362
|
+
}, [incoming, dragging])
|
|
363
|
+
|
|
364
|
+
// A model with just one effort keeps its stop in the middle.
|
|
365
|
+
const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
|
|
366
|
+
|
|
367
|
+
return h('div', {
|
|
368
|
+
className: 'dmp-slider',
|
|
369
|
+
'aria-disabled': disabled === true,
|
|
370
|
+
style: { '--dmp-effort-at': position(index) }
|
|
371
|
+
},
|
|
372
|
+
h('div', { className: 'dmp-slider-head' },
|
|
373
|
+
h('span', { className: 'dmp-slider-caption' },
|
|
374
|
+
// The vector marks are CJK outlines; a Latin heading is plain text.
|
|
375
|
+
caption === undefined
|
|
376
|
+
? h(CaptionMark, { label: t.effortHeading, height: 16 })
|
|
377
|
+
: h('span', { className: 'dmp-caption-text' }, caption)),
|
|
378
|
+
h('span', { className: 'dmp-slider-value' }, activeName)),
|
|
379
|
+
|
|
380
|
+
h('div', { className: 'dmp-slider-rail' },
|
|
381
|
+
/**
|
|
382
|
+
* The moving parts live in an INSET track.
|
|
383
|
+
*
|
|
384
|
+
* Stops are drawn inside the rail rather than on its very edge — a dot
|
|
385
|
+
* sitting flush against the rounded end reads as part of the border, not
|
|
386
|
+
* as a stop. The track is inset by the knob's radius on both sides, which
|
|
387
|
+
* is also exactly the knob's own travel: the native range input spans the
|
|
388
|
+
* rail with a thumb that size, so its centre runs from inset to width
|
|
389
|
+
* minus inset. Keeping all three — fill, dots, knob — on THIS box means
|
|
390
|
+
* the percentage positions from `--dmp-effort-at` line up without any
|
|
391
|
+
* per-element inset arithmetic.
|
|
392
|
+
*/
|
|
393
|
+
h('div', { className: 'dmp-slider-track' },
|
|
394
|
+
h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
|
|
395
|
+
h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
|
|
396
|
+
efforts.map((effort, at) => h('span', {
|
|
397
|
+
key: effort.id,
|
|
398
|
+
className: `dmp-slider-dot${at <= index ? ' dmp-slider-dot-lit' : ''}`,
|
|
399
|
+
style: { left: position(at) }
|
|
400
|
+
}))),
|
|
401
|
+
/**
|
|
402
|
+
* The knob is an SVG circle, not a border-radius box.
|
|
403
|
+
*
|
|
404
|
+
* A `<div>` with `border-radius` is only round as long as nothing else
|
|
405
|
+
* controls its box, and something in the host does: the same CSS that is
|
|
406
|
+
* perfectly round in isolation rendered as a rounded rectangle inside the
|
|
407
|
+
* app, at every radius value tried. An SVG circle's shape comes from its
|
|
408
|
+
* geometry, so no `border-radius`, `padding`, or inherited box rule can
|
|
409
|
+
* square it off.
|
|
410
|
+
*/
|
|
411
|
+
h('svg', {
|
|
412
|
+
className: 'dmp-slider-thumb',
|
|
413
|
+
viewBox: '0 0 28 28',
|
|
414
|
+
'aria-hidden': true
|
|
415
|
+
},
|
|
416
|
+
h('circle', {
|
|
417
|
+
// Outer edge = r + stroke/2 = 13.25 + 0.75 = 14 units, and the 28-unit
|
|
418
|
+
// box is drawn at 32px — so the disc's radius is exactly 16px, the same
|
|
419
|
+
// as the rail's end cap. Smaller and the blue cap peeks out as a thin
|
|
420
|
+
// rim around the knob at the first stop; larger and the knob spills
|
|
421
|
+
// past the pill.
|
|
422
|
+
cx: 14,
|
|
423
|
+
cy: 14,
|
|
424
|
+
r: 13.25,
|
|
425
|
+
// Fill and ring come from CSS so the two themes can differ: the dark
|
|
426
|
+
// theme keeps the knob the panel's own grey with a black ring, the light
|
|
427
|
+
// theme flips the fill to white and keeps the black ring.
|
|
428
|
+
fill: 'var(--dmp-thumb-fill, currentColor)',
|
|
429
|
+
stroke: 'var(--dmp-thumb-ring, #000)',
|
|
430
|
+
strokeWidth: 1.5
|
|
431
|
+
}))),
|
|
432
|
+
h('input', {
|
|
433
|
+
type: 'range',
|
|
434
|
+
ref: inputRef,
|
|
435
|
+
className: 'dmp-slider-input',
|
|
436
|
+
min: 0,
|
|
437
|
+
max: Math.max(0, count - 1),
|
|
438
|
+
step: 1,
|
|
439
|
+
// Uncontrolled: the DOM owns the position during a gesture, so a parent
|
|
440
|
+
// render cannot snap it back. `defaultValue` seeds it on mount, and the
|
|
441
|
+
// effect above re-seeds it whenever the level changes from outside.
|
|
442
|
+
defaultValue: incoming,
|
|
443
|
+
disabled: disabled === true || count <= 1,
|
|
444
|
+
'aria-label': t.effortHeading,
|
|
445
|
+
'aria-valuetext': activeName,
|
|
446
|
+
// Live feedback only — no Host call, so the thumb tracks at full speed.
|
|
447
|
+
onInput: (event) => {
|
|
448
|
+
const at = Number(event.target.value)
|
|
449
|
+
if (Number.isFinite(at)) setDragging(at)
|
|
450
|
+
},
|
|
451
|
+
// The commit: browsers fire `change` on release (and on every key press).
|
|
452
|
+
onChange: (event) => {
|
|
453
|
+
const at = Number(event.target.value)
|
|
454
|
+
const next = efforts[at]
|
|
455
|
+
setDragging(null)
|
|
456
|
+
if (next !== undefined) onChange(next.id)
|
|
457
|
+
}
|
|
458
|
+
})),
|
|
459
|
+
|
|
460
|
+
h('div', { className: 'dmp-slider-legend' },
|
|
461
|
+
efforts.map((effort, at) => h('button', {
|
|
462
|
+
key: effort.id,
|
|
463
|
+
type: 'button',
|
|
464
|
+
className: `dmp-slider-tick${at === index ? ' dmp-slider-tick-active' : ''}`,
|
|
465
|
+
disabled: disabled === true,
|
|
466
|
+
'aria-pressed': at === index,
|
|
467
|
+
'data-edge': count <= 1 ? undefined : at === 0 ? 'start' : at === count - 1 ? 'end' : undefined,
|
|
468
|
+
title: effort.name ?? effort.id,
|
|
469
|
+
style: { left: position(at) },
|
|
470
|
+
onClick: () => onChange(effort.id)
|
|
471
|
+
}, effort.name ?? effort.id))),
|
|
472
|
+
h('p', { className: 'dmp-slider-hint' }, t.effortHint))
|
|
473
|
+
}
|
|
563
474
|
}
|
|
564
475
|
|
|
565
476
|
//# module: src/ui/styles.js
|
|
@@ -567,92 +478,387 @@ function Chevron({ h, open }) {
|
|
|
567
478
|
* The picker's stylesheet.
|
|
568
479
|
*
|
|
569
480
|
* Host theme tokens only, plus the two composer variables the row sets when its
|
|
570
|
-
* controls no longer fit on one line. Every class is prefixed
|
|
481
|
+
* controls no longer fit on one line. Every class is prefixed dmp- so a copied
|
|
571
482
|
* host pattern can never collide with the host's own rules.
|
|
483
|
+
*
|
|
484
|
+
* Sizing follows the shipped menu rather than inventing its own scale: the panel
|
|
485
|
+
* is max-content wide (so a short list stays narrow instead of stretching to a
|
|
486
|
+
* fixed width), rows are 34px tall with 5px/7px padding, and the panel pads 4px.
|
|
487
|
+
* Those numbers are what makes the replacement feel like part of the composer
|
|
488
|
+
* instead of a dialog dropped on top of it.
|
|
572
489
|
*/
|
|
573
490
|
const CSS = `
|
|
574
|
-
.dmp-root { position: relative; display: flex; align-items: center; gap:
|
|
491
|
+
.dmp-root { position: relative; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
575
492
|
/* One popover anchor per control: the model list and the level list open above
|
|
576
493
|
their own button and never overlap each other. */
|
|
577
494
|
.dmp-control { position: relative; display: flex; align-items: center; min-width: 0; }
|
|
578
|
-
|
|
579
|
-
|
|
495
|
+
/* The model tag keeps a stable box too, for the same reason: switching models must
|
|
496
|
+
not move the level tag beside it. It is capped rather than fixed so a long model
|
|
497
|
+
name still gets room, but the cap is what the layout reserves. */
|
|
498
|
+
.dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0;
|
|
499
|
+
max-width: min(220px, 34cqw); height: 28px; padding: 0 4px 0 8px; border: 0;
|
|
500
|
+
border-radius: var(--dsw-radius-sm, 6px); cursor: pointer;
|
|
580
501
|
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
581
502
|
font: inherit; font-size: 13px; line-height: 20px; }
|
|
582
|
-
|
|
583
|
-
|
|
503
|
+
/* The level tag has a FIXED width.
|
|
504
|
+
Level names differ in length ("Low" vs "X-High"), so a content-sized tag made
|
|
505
|
+
the whole composer row shift every time the level changed — and pushed the
|
|
506
|
+
model button sideways with it. A fixed box keeps both controls still.
|
|
507
|
+
|
|
508
|
+
The width is sized to the longest name a provider is likely to send: "Medium"
|
|
509
|
+
measures ~50px at 12px/600, and the earlier 74px box only left 44px for text,
|
|
510
|
+
so it rendered as "Medi…". 92px leaves 56px, which also covers "Extreme" and
|
|
511
|
+
"Minimal".
|
|
512
|
+
|
|
513
|
+
The label is CENTRED in the button, which needs the caret out of the flex flow:
|
|
514
|
+
a normal flex child reserves 18px on the right only, so centring the label in
|
|
515
|
+
what was left pushed it ~7px left of the button's true centre. Absolutely
|
|
516
|
+
positioning the caret and giving the button 18px padding on BOTH sides makes
|
|
517
|
+
the label's own box symmetric, so text-align: center lands on the button's
|
|
518
|
+
centre. 18px is exactly the caret's own width (14px) plus its 4px inset, which
|
|
519
|
+
also stops a long label from running underneath it.
|
|
520
|
+
The caret keeps its transform free for the open-state rotation by centring
|
|
521
|
+
with margin-top instead of translateY. */
|
|
522
|
+
.dmp-trigger-effort { position: relative; width: 92px; box-sizing: border-box;
|
|
523
|
+
padding: 0 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
524
|
+
.dmp-trigger-effort > .dmp-chevron { position: absolute; right: 4px; top: 50%;
|
|
525
|
+
margin-top: -7px; }
|
|
526
|
+
/* The tag names the level; it is not a brightness meter. Taking the ramp colour here
|
|
527
|
+
made "Low" render dim grey and "High" near white, so the control appeared to change
|
|
528
|
+
weight as the level moved. The ramp stays on the track, where it means intensity. */
|
|
529
|
+
.dmp-trigger-effort-text { flex: 1 1 auto; min-width: 0; text-align: center;
|
|
530
|
+
color: var(--dsw-alias-label-primary); font-weight: 600; }
|
|
531
|
+
/* Hover and open share ONE background, and it is the translucent interactive
|
|
532
|
+
token, not the opaque layer-2 surface.
|
|
533
|
+
The layer-2 token resolves to the composer's own surface colour in both themes
|
|
534
|
+
(light white on white, dark 2c2c2e on 2c2c2e), so hovering this tag painted
|
|
535
|
+
nothing at all — the level button was the only control in the row with no
|
|
536
|
+
pointer feedback. The translucent token composes over whatever is behind it
|
|
537
|
+
and matches the model tag beside it. */
|
|
538
|
+
.dmp-trigger-effort:hover:not(:disabled),
|
|
539
|
+
.dmp-trigger-effort[aria-expanded="true"] {
|
|
540
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-interactive-bg-active));
|
|
541
|
+
color: var(--dsw-alias-label-primary); }
|
|
542
|
+
.dmp-trigger-effort:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
|
|
584
543
|
.dmp-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
585
|
-
.dmp-trigger:disabled { color: var(--dsw-alias-state-idle-primary); cursor: default; }
|
|
586
|
-
.dmp-trigger-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
|
|
544
|
+
.dmp-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-state-idle-primary)); cursor: default; }
|
|
587
545
|
.dmp-trigger-text { display: var(--dsh-composer-model-text-display, block);
|
|
588
546
|
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
589
|
-
.dmp-chevron { flex: none; color: var(--dsw-alias-label-secondary);
|
|
547
|
+
.dmp-chevron { flex: none; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary));
|
|
548
|
+
transition: transform .12s; }
|
|
590
549
|
.dmp-chevron-open { transform: rotate(180deg); }
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
.dmp-
|
|
602
|
-
.dmp-
|
|
603
|
-
color: var(--dsw-alias-label-
|
|
604
|
-
|
|
605
|
-
|
|
550
|
+
/* Content-sized, like the shipped menu: a two-model list must not be as wide as a
|
|
551
|
+
twenty-model one. The caps only exist so a long model name cannot run off the
|
|
552
|
+
viewport. */
|
|
553
|
+
.dmp-panel { position: absolute; bottom: calc(100% + 6px + var(--dmp-notice-lift, 0px)); z-index: 1000;
|
|
554
|
+
width: max-content; min-width: min(240px, 100vw - 32px); max-width: min(360px, 92vw);
|
|
555
|
+
max-height: min(320px, 60vh); display: flex; flex-direction: column; padding: 4px;
|
|
556
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-lg, 10px);
|
|
557
|
+
background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
|
|
558
|
+
box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv3));
|
|
559
|
+
color: var(--dsw-alias-label-primary); overflow: hidden; }
|
|
560
|
+
.dmp-search { flex: none; padding: 0 0 4px; }
|
|
561
|
+
.dmp-search input { width: 100%; box-sizing: border-box; padding: 5px 7px; font: inherit; font-size: 12.5px;
|
|
562
|
+
color: var(--dsw-alias-label-primary); background: transparent;
|
|
563
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px); outline: none; }
|
|
564
|
+
.dmp-search input:focus-visible { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
565
|
+
/* The scroll container.
|
|
566
|
+
*
|
|
567
|
+
* The scrollbar is NOT styled here. The host owns scrollbar appearance through two
|
|
568
|
+
* CSS variables that its own stylesheets consume, so setting them on the panel is
|
|
569
|
+
* the whole job: the shell then paints a thin, rounded, theme-aware bar that matches
|
|
570
|
+
* every other DSH popover. Writing -webkit-scrollbar rules as well would override
|
|
571
|
+
* the host's treatment and is what made this panel look different from the rest of
|
|
572
|
+
* the app.
|
|
573
|
+
*
|
|
574
|
+
* This mirrors the shipped picker and the thinking-slider plugin, which both do
|
|
575
|
+
* exactly this much. */
|
|
576
|
+
.dmp-list { flex: 1; overflow-y: auto; min-height: 0;
|
|
577
|
+
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
578
|
+
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); }
|
|
579
|
+
.dmp-group { padding: 6px 7px 2px; font-size: 11px; letter-spacing: .04em;
|
|
580
|
+
color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
581
|
+
.dmp-item { display: flex; align-items: center; width: 100%; box-sizing: border-box;
|
|
582
|
+
min-height: 34px; padding: 5px 7px; border: 0; border-radius: var(--dsw-radius-md, 8px);
|
|
583
|
+
background: transparent; cursor: pointer; text-align: left;
|
|
606
584
|
color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
|
|
607
585
|
.dmp-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
608
586
|
.dmp-item-current { color: var(--dsw-alias-brand-primary); font-weight: 600; }
|
|
609
587
|
.dmp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
610
|
-
.dmp-
|
|
611
|
-
color: var(--dsw-alias-label-secondary); }
|
|
612
|
-
/* The level list holds short labels, so it stays narrow and tight. */
|
|
613
|
-
.dmp-panel-narrow { width: min(220px, 60vw); max-height: 260px; }
|
|
614
|
-
.dmp-note { padding: 10px 12px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
588
|
+
.dmp-note { padding: 8px 7px; font-size: 12px; line-height: 18px;
|
|
589
|
+
color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
615
590
|
.dmp-note-error { color: var(--dsw-alias-state-error-primary); }
|
|
616
591
|
.dmp-retry { margin-top: 6px; padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l2);
|
|
617
|
-
border-radius: 6px; background: transparent; color: inherit;
|
|
618
|
-
|
|
592
|
+
border-radius: var(--dsw-radius-sm, 6px); background: transparent; color: inherit;
|
|
593
|
+
font: inherit; font-size: 12px; cursor: pointer; }
|
|
594
|
+
|
|
595
|
+
/* The padding is what keeps the heading and the hint off the panel edge; the values
|
|
596
|
+
are generous because this panel has no menu rows to create natural breathing room. */
|
|
597
|
+
.dmp-panel-slider { width: max-content; box-sizing: border-box;
|
|
598
|
+
min-width: min(322px, 100vw - 32px); max-width: min(374px, 100vw - 32px);
|
|
599
|
+
padding: 17px 19px 15px; border-radius: var(--dsw-radius-lg, 12px); }
|
|
600
|
+
/* The type stack, in two parts.
|
|
601
|
+
*
|
|
602
|
+
* The level names are Latin ("Low", "Medium", "X-High") and get the host's own
|
|
603
|
+
* brand face, Montserrat, which the theme plugin already ships at 300/400/500 —
|
|
604
|
+
* so this costs no download and matches the host's own headings. The CJK copy
|
|
605
|
+
* ("思考强度", the hint line) has no glyphs in Montserrat, so the stack falls
|
|
606
|
+
* through to the platform UI face, which is what the host uses for body text.
|
|
607
|
+
*
|
|
608
|
+
* Nothing here sets a size: that stays with each element, so a host font-size
|
|
609
|
+
* change still reaches this control. The slider inherits this from the panel it is
|
|
610
|
+
* always rendered inside, so it is not named here. */
|
|
611
|
+
.dmp-panel {
|
|
612
|
+
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, -apple-system,
|
|
613
|
+
'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', sans-serif); }
|
|
614
|
+
.dmp-slider { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
|
|
615
|
+
.dmp-slider-head { display: flex; align-items: center; gap: 14px; margin-bottom: 5px; }
|
|
616
|
+
/* The heading is vector art, not text: see CaptionMark.js for why. It only needs a
|
|
617
|
+
colour — which it takes from the theme token below through currentColor — and a
|
|
618
|
+
display box that sits it on the same line as the value pill. */
|
|
619
|
+
.dmp-slider-caption { display: flex; align-items: center;
|
|
620
|
+
color: var(--dsw-alias-label-primary); }
|
|
621
|
+
.dmp-caption-mark { display: block; width: auto; }
|
|
622
|
+
/* The Latin heading, used wherever the vector mark would be unreadable. It takes
|
|
623
|
+
the heading's own size so the two stay interchangeable on the same line. */
|
|
624
|
+
.dmp-caption-text { display: block; font-size: 13px; font-weight: 600; line-height: 16px;
|
|
625
|
+
letter-spacing: .01em; white-space: nowrap; }
|
|
626
|
+
/* The value pill is sized close to the heading beside it, so the two read as one
|
|
627
|
+
line: the earlier 19px box with 11.5px type made the current level look like a
|
|
628
|
+
footnote next to the title. 26px / 14px keeps the pair balanced and still fits
|
|
629
|
+
a long level name. */
|
|
630
|
+
.dmp-slider-value { margin-left: auto; display: inline-flex; align-items: center;
|
|
631
|
+
height: 22px; box-sizing: border-box; padding: 0 8px; border-radius: 8px;
|
|
632
|
+
font-size: 12px; line-height: 1; font-weight: 600; white-space: nowrap;
|
|
633
|
+
letter-spacing: .01em;
|
|
634
|
+
/* The pill is a label, not a rank meter: it must read the SAME at every level.
|
|
635
|
+
It used to take the level's ramp colour, so Low rendered dim grey and Max near
|
|
636
|
+
white — the same component looking like two different things depending on the
|
|
637
|
+
setting. The ramp colour stays on the track, where it encodes intensity. */
|
|
638
|
+
color: var(--dsw-alias-label-primary);
|
|
639
|
+
background: var(--dsw-alias-interactive-bg-hover); }
|
|
640
|
+
/* Reasoning effort: the slider.
|
|
641
|
+
---------------------------------------------------------------------------
|
|
642
|
+
A thick pill-shaped track with a solid accent fill and a disc riding on it. The
|
|
643
|
+
thick track is what makes the control legible at a glance — a hairline rail with
|
|
644
|
+
a small dot reads as a status indicator, not something you can drag.
|
|
645
|
+
|
|
646
|
+
The fill and the active label take the host's business-primary token — the same
|
|
647
|
+
accent DSH uses for its own interactive highlights — so the control recolours
|
|
648
|
+
with the theme instead of carrying a blue that only suits one of them. */
|
|
649
|
+
/* The knob is the rail's full height (32px). The two stop offsets are deliberately
|
|
650
|
+
UNEQUAL:
|
|
651
|
+
--dmp-stop-left 32px the first stop sits a full knob diameter in from the
|
|
652
|
+
left cap, so the knob stops short of that end;
|
|
653
|
+
--dmp-stop-right 16px the last stop is one knob RADIUS in from the right cap,
|
|
654
|
+
so the knob reaches that end flush.
|
|
655
|
+
That asymmetry is the requested behaviour, not an oversight. The dots, the
|
|
656
|
+
labels, the fill and the native input's travel all derive from these two numbers,
|
|
657
|
+
which is why they are variables rather than four copies of the same value. */
|
|
658
|
+
.dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
|
|
659
|
+
--dmp-stop-left: 28px; --dmp-stop-right: 14px; }
|
|
660
|
+
/* Radius is a LITERAL 17px on a 32px-tall rail — deliberately ONE more than half the
|
|
661
|
+
height, so the ends curve a touch harder than a true semicircle and read as round
|
|
662
|
+
rather than as pillowed caps. Anything below half looks square; this is the
|
|
663
|
+
user-tuned value.
|
|
664
|
+
The two numbers are tied: change the height, move the radius with it.
|
|
665
|
+
|
|
666
|
+
clip-path does the actual shaping. A border-radius of 16px on this 32px box was
|
|
667
|
+
measured painting an ~10px corner in the host, so the ends kept reading as a
|
|
668
|
+
rounded rectangle no matter what the declaration said. clip-path: inset(... round
|
|
669
|
+
17px) is a different property: it clips the element AND its children to the same
|
|
670
|
+
shape, so neither the track nor the fill can be squared off by whatever was
|
|
671
|
+
winning on border-radius. border-radius stays as the fallback. */
|
|
672
|
+
.dmp-slider-rail { position: relative; height: var(--dmp-rail-height);
|
|
673
|
+
margin: 12px 0 4px; border-radius: 15px; flex: none;
|
|
674
|
+
clip-path: inset(0 round 15px);
|
|
675
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
|
|
676
|
+
/* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
|
|
677
|
+
stop and 100% the last — the knob's travel, the dots, the labels and the fill all
|
|
678
|
+
read from this one box. */
|
|
679
|
+
.dmp-slider-track { position: absolute; top: 0; bottom: 0;
|
|
680
|
+
left: var(--dmp-stop-left); right: var(--dmp-stop-right); }
|
|
681
|
+
/* The fill starts at the RAIL's left edge and ends on the knob's centre line.
|
|
682
|
+
|
|
683
|
+
Starting it at the stop axis left a grey stub at the left cap, and the rail's
|
|
684
|
+
clip gives that end its shape, so the run is pulled back to the rail's edge.
|
|
685
|
+
|
|
686
|
+
It carries NO radius of its own: it is a plain rectangle clipped by the rail,
|
|
687
|
+
which is what makes its left cap identical to the track's. */
|
|
688
|
+
.dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
|
|
689
|
+
left: calc(var(--dmp-stop-left) * -1);
|
|
690
|
+
width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
|
|
691
|
+
background: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
|
|
692
|
+
z-index: 1; transition: width .15s ease-out; pointer-events: none; }
|
|
693
|
+
.dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
|
|
694
|
+
pointer-events: none; z-index: 2; }
|
|
695
|
+
/* Stops stay SMALL and GREY: they mark the stations, the knob is what you grab.
|
|
696
|
+
The reached stop used to take the panel's surface colour, which is near-black in
|
|
697
|
+
the dark theme — a black pinhole punched into the blue. Both states now use the
|
|
698
|
+
label ramp, so a dot reads as grey on the track and as light grey on the fill. */
|
|
699
|
+
.dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
|
|
700
|
+
border-radius: 50%; transform: translate(-50%, -50%);
|
|
701
|
+
background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
|
|
702
|
+
transition: opacity .15s ease-out, background .15s ease-out; }
|
|
703
|
+
/* A reached stop sits on the coloured fill, so it lightens instead of going dark. */
|
|
704
|
+
.dmp-slider-dot-lit { background: var(--dsw-alias-label-secondary, currentColor); opacity: .9; }
|
|
705
|
+
/* The knob fills the rail's height and is drawn as an SVG, so no inherited
|
|
706
|
+
border-radius, padding or box-sizing rule can square it off. Its size comes from
|
|
707
|
+
--dmp-thumb-size; the stop offsets and the input's travel are derived from the
|
|
708
|
+
same variable so the three cannot drift apart. */
|
|
709
|
+
.dmp-slider-thumb { position: absolute; top: 50%; left: var(--dmp-effort-at);
|
|
710
|
+
width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
|
|
711
|
+
margin-left: calc(var(--dmp-thumb-size) / -2);
|
|
712
|
+
margin-top: calc(var(--dmp-thumb-size) / -2);
|
|
713
|
+
display: block; overflow: visible; pointer-events: none; z-index: 5;
|
|
714
|
+
/* The knob takes the panel's grey in dark and pure white in light, with a black
|
|
715
|
+
ring in both. The two values swing with the host stylesheet's own media query,
|
|
716
|
+
so no JS theme detection is involved. */
|
|
717
|
+
--dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
|
|
718
|
+
--dmp-thumb-ring: #000;
|
|
719
|
+
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
|
|
720
|
+
transition: left .15s ease-out, transform .12s ease; }
|
|
721
|
+
.dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
|
|
722
|
+
.dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
|
|
723
|
+
box-shadow: 0 0 0 3px color-mix(in srgb,
|
|
724
|
+
var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 35%, transparent); }
|
|
725
|
+
/* The range input spans from the first stop's reach to the rail's right edge, and
|
|
726
|
+
its native thumb is the knob's size. That makes its invisible thumb's centre
|
|
727
|
+
travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
|
|
728
|
+
knob is drawn on. Spanning the full rail instead would start the travel one
|
|
729
|
+
radius short of the first stop, and the pointer would disagree with the knob. */
|
|
730
|
+
.dmp-slider-input { position: absolute; z-index: 6; top: 0;
|
|
731
|
+
left: calc(var(--dmp-stop-left) - var(--dmp-thumb-size) / 2);
|
|
732
|
+
width: calc(100% - var(--dmp-stop-left) + var(--dmp-thumb-size) / 2);
|
|
733
|
+
height: var(--dmp-rail-height); margin: 0; cursor: pointer;
|
|
734
|
+
-webkit-appearance: none; appearance: none; background: transparent;
|
|
735
|
+
/* Two-level invisibility: opacity alone leaves the native thumb able to paint
|
|
736
|
+
through on some engines, which reads as a stray rectangle riding the track. */
|
|
737
|
+
opacity: 0; color: transparent; border: 0; }
|
|
738
|
+
.dmp-slider-input::-webkit-slider-runnable-track { background: transparent; border: 0; }
|
|
739
|
+
/* The native thumb is sized to the painted one so pointer travel matches, then made
|
|
740
|
+
fully transparent: leaving it visible is what produced a square riding the track. */
|
|
741
|
+
.dmp-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
|
|
742
|
+
width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
|
|
743
|
+
background: transparent; border: 0; }
|
|
744
|
+
.dmp-slider-input::-moz-range-thumb { width: var(--dmp-thumb-size); height: var(--dmp-thumb-size); border: 0; }
|
|
745
|
+
.dmp-slider-input:disabled { cursor: default; }
|
|
746
|
+
.dmp-slider[aria-disabled="true"] { opacity: .5; }
|
|
747
|
+
/* The legend shares the track's inset so a label sits under the stop it names. */
|
|
748
|
+
.dmp-slider-legend { position: relative; height: 16px;
|
|
749
|
+
margin: 0 var(--dmp-stop-right) 0 var(--dmp-stop-left); }
|
|
750
|
+
/* The level labels are Latin, so they take Montserrat explicitly — inheriting from
|
|
751
|
+
the panel would pull the platform face back in. */
|
|
752
|
+
.dmp-slider-tick { position: absolute; top: 0; transform: translateX(-50%);
|
|
753
|
+
padding: 1px 4px; border: 0; border-radius: 6px; background: transparent;
|
|
754
|
+
/* One brightness for every unselected label: the tertiary token sat too close to
|
|
755
|
+
the panel background, so the secondary token lifts all of them equally. */
|
|
756
|
+
color: var(--dsw-alias-label-secondary);
|
|
757
|
+
/* 12px, up from 11px: at 11 the stop names sat visibly below the heading's weight
|
|
758
|
+
and were hard to hit by eye under a 32px rail. */
|
|
759
|
+
font-size: 11px; line-height: 15px;
|
|
760
|
+
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
|
|
761
|
+
white-space: nowrap; cursor: pointer;
|
|
762
|
+
max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
|
|
763
|
+
.dmp-slider-tick[data-edge="start"] { transform: none; margin-left: -4px; }
|
|
764
|
+
.dmp-slider-tick[data-edge="end"] { transform: translateX(-100%); margin-left: 4px; }
|
|
765
|
+
.dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
|
|
766
|
+
background: var(--dsw-alias-interactive-bg-hover); }
|
|
767
|
+
.dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
|
|
768
|
+
.dmp-slider-tick:disabled { cursor: default; }
|
|
769
|
+
/* The active label is marked by COLOUR only, not weight.
|
|
770
|
+
Bolding it made the selected label measurably wider and taller-looking than its
|
|
771
|
+
neighbours, so the labels appeared to change size as the level moved. Keeping one
|
|
772
|
+
weight keeps every label the same box; the accent colour carries the selection. */
|
|
773
|
+
.dmp-slider-tick-active {
|
|
774
|
+
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
775
|
+
.dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
|
|
776
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
777
|
+
font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
|
|
778
|
+
/* Light surfaces need a softer knob: a black ring reads as a hard edge on a
|
|
779
|
+
near-white panel, so the light theme pulls the ring to the host's border colour
|
|
780
|
+
and keeps the fill pure white. The two values swing with the host stylesheet's
|
|
781
|
+
own media query, so no JS theme detection is involved. */
|
|
782
|
+
@media (prefers-color-scheme: light) {
|
|
783
|
+
.dmp-slider-thumb {
|
|
784
|
+
--dmp-thumb-fill: var(--dsw-alias-bg-base);
|
|
785
|
+
--dmp-thumb-ring: var(--dsw-alias-border-l2);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
@media (prefers-reduced-motion: reduce) {
|
|
789
|
+
.dmp-slider-fill, .dmp-slider-thumb { transition: none; }
|
|
790
|
+
}
|
|
791
|
+
/* ---------------------------------------------------------------------------
|
|
792
|
+
Rejected-selection notice
|
|
793
|
+
---------------------------------------------------------------------------
|
|
794
|
+
A failed switch is reported ABOVE the panels, never inside them: the list and
|
|
795
|
+
the slider are for choosing, and a raw "code: message" dumped among the rows
|
|
796
|
+
both crowds the list and scrolls away. Collapsed it is one quiet line; the
|
|
797
|
+
detail is opt-in.
|
|
798
|
+
|
|
799
|
+
It is absolutely positioned above the row so it takes no width from the
|
|
800
|
+
composer, and --dmp-notice-lift (measured in ModelPicker) is what raises an
|
|
801
|
+
open panel over its measured height instead of on top of it. */
|
|
802
|
+
.dmp-notice { position: absolute; bottom: calc(100% + 6px); left: 0; right: auto;
|
|
803
|
+
z-index: 1100; display: flex; flex-direction: column; align-items: flex-start;
|
|
804
|
+
width: max-content; max-width: min(360px, 92vw); box-sizing: border-box;
|
|
805
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px);
|
|
806
|
+
background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
|
|
807
|
+
box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv2));
|
|
808
|
+
overflow: hidden; }
|
|
809
|
+
.dmp-notice-toggle { display: flex; align-items: center; gap: 6px; width: 100%;
|
|
810
|
+
box-sizing: border-box; padding: 6px 8px; border: 0; background: transparent;
|
|
811
|
+
color: var(--dsw-alias-state-error-primary); font: inherit; font-size: 12px;
|
|
812
|
+
line-height: 18px; text-align: left; cursor: pointer; }
|
|
813
|
+
.dmp-notice-toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
814
|
+
.dmp-notice-toggle:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: -2px; }
|
|
815
|
+
.dmp-notice-toggle .dmp-chevron { margin-left: auto; }
|
|
816
|
+
.dmp-notice-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
817
|
+
/* The raw provider error: monospaced so a "code: message" pair is readable, and
|
|
818
|
+
selectable so it can be copied into a bug report. */
|
|
819
|
+
.dmp-notice-detail { width: 100%; box-sizing: border-box; padding: 6px 8px;
|
|
820
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
821
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
822
|
+
font-size: 11.5px; line-height: 16px; white-space: pre-wrap; word-break: break-word;
|
|
823
|
+
color: var(--dsw-alias-label-secondary); user-select: text; }`
|
|
619
824
|
|
|
620
825
|
//# module: src/ui/dict.js
|
|
621
|
-
|
|
826
|
+
/**
|
|
622
827
|
* The picker's two dictionaries.
|
|
623
828
|
*
|
|
624
829
|
* Kept as a plain object rather than the Client locale service because the
|
|
625
830
|
* strings are this component's own copy, not a namespace other plugins
|
|
626
831
|
* translate. Adding a language is one more entry here.
|
|
832
|
+
*
|
|
833
|
+
* Only keys the component actually reads belong here: an entry nobody looks up
|
|
834
|
+
* is a string that silently rots, and a reviewer cannot tell it is dead.
|
|
627
835
|
*/
|
|
628
836
|
const DICT = {
|
|
629
837
|
'zh-CN': {
|
|
630
838
|
placeholder: '搜索模型',
|
|
631
839
|
empty: '没有匹配的模型',
|
|
632
840
|
loading: '正在读取模型列表…',
|
|
633
|
-
failed: '读取模型列表失败',
|
|
634
841
|
retry: '重试',
|
|
635
842
|
noCatalog: '当前没有可用模型',
|
|
636
843
|
trigger: (name) => `${name}(点此切换模型)`,
|
|
637
844
|
effortTitle: (name) => `思考档位:${name}(点此调整)`,
|
|
845
|
+
effortHeading: '思考强度',
|
|
846
|
+
effortHint: '更高的强度通常需要更多思考时间。',
|
|
638
847
|
switching: '切换中…',
|
|
639
|
-
switchFailed: '切换失败'
|
|
640
|
-
providerDefault: '默认',
|
|
641
|
-
groupFailed: (name) => `${name} 加载失败`
|
|
848
|
+
switchFailed: '切换失败'
|
|
642
849
|
},
|
|
643
850
|
en: {
|
|
644
851
|
placeholder: 'Search models',
|
|
645
852
|
empty: 'No matching model',
|
|
646
853
|
loading: 'Loading models…',
|
|
647
|
-
failed: 'Could not load the model list',
|
|
648
854
|
retry: 'Retry',
|
|
649
855
|
noCatalog: 'No model is currently available',
|
|
650
856
|
trigger: (name) => `${name} (click to switch model)`,
|
|
651
857
|
effortTitle: (name) => `Reasoning level: ${name} (click to change)`,
|
|
858
|
+
effortHeading: 'Reasoning effort',
|
|
859
|
+
effortHint: 'Higher effort usually takes more time to think.',
|
|
652
860
|
switching: 'Switching…',
|
|
653
|
-
switchFailed: 'Switch failed'
|
|
654
|
-
providerDefault: 'Default',
|
|
655
|
-
groupFailed: (name) => `${name} failed to load`
|
|
861
|
+
switchFailed: 'Switch failed'
|
|
656
862
|
}
|
|
657
863
|
}
|
|
658
864
|
|
|
@@ -671,7 +877,27 @@ function dictionaryFor(localeId) {
|
|
|
671
877
|
|
|
672
878
|
|
|
673
879
|
|
|
674
|
-
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Turn a failed `select` result into the two strings the notice shows.
|
|
884
|
+
*
|
|
885
|
+
* `ModelDirectory.select` resolves to `{ ok: false, error: { code, message } }`
|
|
886
|
+
* on a rejected selection. The summary is the short, always-visible line; the
|
|
887
|
+
* detail is what expanding the notice reveals, and it is the only place the
|
|
888
|
+
* provider's raw error text appears — the panels themselves stay free of it.
|
|
889
|
+
*
|
|
890
|
+
* @param result - the value `select` resolved to, possibly undefined.
|
|
891
|
+
* @param t - the active dictionary.
|
|
892
|
+
* @returns `{ summary, detail }`.
|
|
893
|
+
*/
|
|
894
|
+
function failureOf(result, t) {
|
|
895
|
+
const error = result?.error
|
|
896
|
+
if (error === undefined || error === null) return { summary: t.switchFailed, detail: '' }
|
|
897
|
+
const code = typeof error.code === 'string' ? error.code : ''
|
|
898
|
+
const message = typeof error.message === 'string' ? error.message : String(error)
|
|
899
|
+
return { summary: t.switchFailed, detail: code.length > 0 ? `${code}: ${message}` : message }
|
|
900
|
+
}
|
|
675
901
|
|
|
676
902
|
/**
|
|
677
903
|
* The composer's model seat: a searchable, provider-grouped picker plus a
|
|
@@ -681,59 +907,114 @@ const LABEL_SLOT = 'conversation.input.model.label'
|
|
|
681
907
|
* `current` (the effective selection, projection or default), `routable`
|
|
682
908
|
* (whether the Host would still accept it), `pending` (the selection being
|
|
683
909
|
* settled), `failures` (provider groups that failed to load) and `error`.
|
|
684
|
-
* Deriving those in the component is what made an in-flight switch invisible and
|
|
685
|
-
* a broken provider group silently absent.
|
|
686
910
|
*
|
|
687
|
-
* @param
|
|
688
|
-
*
|
|
689
|
-
* @returns the rendered control, or null when the session cannot change models.
|
|
911
|
+
* @param React - the React runtime.
|
|
912
|
+
* @returns the slot component.
|
|
690
913
|
*/
|
|
691
914
|
function createModelPicker(React) {
|
|
692
915
|
const h = React.createElement
|
|
916
|
+
const EffortSlider = createEffortSlider(React)
|
|
693
917
|
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
694
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Subscribe to any `{ getSnapshot, subscribe }` source.
|
|
921
|
+
*
|
|
922
|
+
* Both sources this component reads — the session directory and the locale
|
|
923
|
+
* service — expose exactly that pair, so one hook covers them. It lives here
|
|
924
|
+
* rather than in a shared module because it needs the component's own hooks,
|
|
925
|
+
* which are bound to this factory's React instance.
|
|
926
|
+
*
|
|
927
|
+
* @param source - the observable source, or undefined while it is not resolved.
|
|
928
|
+
* @returns the current snapshot.
|
|
929
|
+
*/
|
|
930
|
+
const useSnapshot = (source) => {
|
|
931
|
+
const [value, setValue] = useState(() => source?.getSnapshot?.())
|
|
932
|
+
useEffect(() => {
|
|
933
|
+
if (source === undefined || source === null) return undefined
|
|
934
|
+
const update = () => setValue(source.getSnapshot())
|
|
935
|
+
update()
|
|
936
|
+
return source.subscribe(update)
|
|
937
|
+
}, [source])
|
|
938
|
+
return value
|
|
939
|
+
}
|
|
940
|
+
|
|
695
941
|
return function ModelPicker(props) {
|
|
696
|
-
|
|
942
|
+
/**
|
|
943
|
+
* The props bundle the shipped registration hands its own component.
|
|
944
|
+
*
|
|
945
|
+
* `directory` is the session directory's STORE (already subscribed-ready),
|
|
946
|
+
* `load` asks it to refresh, `select` submits a triple, and `available` says
|
|
947
|
+
* whether this session may change models at all. Nothing here reaches behind
|
|
948
|
+
* that bundle: the data layer is the shipped one, this file is the view.
|
|
949
|
+
*/
|
|
950
|
+
const { locked, directory, available, load, select: submit } = props
|
|
697
951
|
|
|
698
|
-
|
|
952
|
+
/**
|
|
953
|
+
* The active language, followed live.
|
|
954
|
+
*
|
|
955
|
+
* `localeSource` is the locale service itself (`getSnapshot` / `subscribe`),
|
|
956
|
+
* so a language switch made while the composer is on screen re-renders with
|
|
957
|
+
* the new dictionary. `localeId` is the value resolved when the seat was
|
|
958
|
+
* registered, used only until the first snapshot arrives.
|
|
959
|
+
*/
|
|
960
|
+
const localeState = useSnapshot(props.localeSource)
|
|
961
|
+
const localeId = typeof localeState?.active === 'string' && localeState.active.length > 0
|
|
962
|
+
? localeState.active
|
|
963
|
+
: typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
|
|
699
964
|
const t = dictionaryFor(localeId)
|
|
965
|
+
// The panel heading is vector art drawn from CJK outlines, so it only reads
|
|
966
|
+
// as words in Chinese; every other language prints the plain string.
|
|
967
|
+
const glyphCaption = String(localeId).toLowerCase().startsWith('zh')
|
|
700
968
|
|
|
701
969
|
// One panel at a time: the model list and the effort list are separate
|
|
702
970
|
// controls, and opening either closes the other.
|
|
703
971
|
const [panel, setPanel] = useState(null)
|
|
704
972
|
const [query, setQuery] = useState('')
|
|
973
|
+
// The level the thumb should sit on right now, before the Host confirms.
|
|
974
|
+
// Null means "follow the directory".
|
|
975
|
+
const [draftEffort, setDraftEffort] = useState(null)
|
|
976
|
+
// A rejected selection. Kept out of the panels on purpose: the notice above
|
|
977
|
+
// them is collapsed until the user asks for the raw error.
|
|
978
|
+
const [failure, setFailure] = useState(null)
|
|
979
|
+
const [noticeOpen, setNoticeOpen] = useState(false)
|
|
980
|
+
const [noticeLift, setNoticeLift] = useState(0)
|
|
705
981
|
const rootRef = useRef(null)
|
|
982
|
+
const noticeRef = useRef(null)
|
|
983
|
+
// Whether the most recent action was a load or a selection. `state.error`
|
|
984
|
+
// carries both, and only a LOAD failure belongs inside the model panel.
|
|
985
|
+
const lastActionRef = useRef('load')
|
|
706
986
|
|
|
707
|
-
const state =
|
|
708
|
-
// `catalogFace` is the catalogue controller, not a store: its snapshot source
|
|
709
|
-
// is `store`. Passing the controller itself made the hook call a missing
|
|
710
|
-
// `getSnapshot` on first render.
|
|
711
|
-
const catalogState = readSnapshot(useState, useEffect, catalogFace?.store)
|
|
987
|
+
const state = useSnapshot(directory) ?? {}
|
|
712
988
|
const groups = Array.isArray(state.groups) ? state.groups : []
|
|
713
989
|
const failures = Array.isArray(state.failures) ? state.failures : []
|
|
714
990
|
const selection = state.current ?? null
|
|
715
991
|
const pending = state.pending ?? null
|
|
716
|
-
|
|
992
|
+
/**
|
|
993
|
+
* Distinguish a MODEL change from a LEVEL change.
|
|
994
|
+
*
|
|
995
|
+
* The directory reports one `selecting` status for both, and `pending` holds
|
|
996
|
+
* whichever triple is in flight. They are not the same event to the user: a
|
|
997
|
+
* level change is a dial, dragged back and forth, and each stop submits. So a
|
|
998
|
+
* pending selection is treated as a model change ONLY when it names a
|
|
999
|
+
* different model than the one in effect — otherwise every slider stop made
|
|
1000
|
+
* the model trigger print "Switching…" and go dim, which read as the model
|
|
1001
|
+
* button flickering on its own.
|
|
1002
|
+
*/
|
|
1003
|
+
const modelKeyOf = (value) => value === null ? null : `${value.provider ?? ''}:${value.model ?? ''}`
|
|
1004
|
+
const swappingModel = pending !== null && modelKeyOf(pending) !== modelKeyOf(selection)
|
|
1005
|
+
const busy = swappingModel
|
|
1006
|
+
|
|
1007
|
+
const doLoad = useCallback(() => {
|
|
1008
|
+
lastActionRef.current = 'load'
|
|
1009
|
+
// Ask the directory to load. It is idempotent: the shipped controller
|
|
1010
|
+
// shares one in-flight request and re-projects when it settles.
|
|
1011
|
+
if (typeof load === 'function') load()
|
|
1012
|
+
}, [load])
|
|
717
1013
|
|
|
718
1014
|
useEffect(() => {
|
|
719
|
-
|
|
720
|
-
* Load the directory, not just a catalogue.
|
|
721
|
-
*
|
|
722
|
-
* In reuse mode `directory` is the shipped one and `catalogFace` is this
|
|
723
|
-
* plugin's own catalogue — loading the latter alone left the shipped
|
|
724
|
-
* directory's catalogue untouched, so its `groups` never advanced past
|
|
725
|
-
* whatever it held when the seat first mounted. `directory.load()` asks the
|
|
726
|
-
* object that actually feeds `state.groups`; its own `load` is idempotent
|
|
727
|
-
* and internally refreshes the catalogue it reads.
|
|
728
|
-
*/
|
|
729
|
-
if (typeof directory?.load === 'function') {
|
|
730
|
-
const outcome = directory.load()
|
|
731
|
-
if (outcome !== undefined && typeof outcome.then === 'function') outcome.catch(() => {})
|
|
732
|
-
} else if (catalogFace !== undefined && catalogFace !== null) {
|
|
733
|
-
catalogFace.load()
|
|
734
|
-
}
|
|
1015
|
+
doLoad()
|
|
735
1016
|
return undefined
|
|
736
|
-
}, [
|
|
1017
|
+
}, [doLoad])
|
|
737
1018
|
|
|
738
1019
|
useEffect(() => {
|
|
739
1020
|
if (panel === null) return undefined
|
|
@@ -749,38 +1030,116 @@ function createModelPicker(React) {
|
|
|
749
1030
|
}
|
|
750
1031
|
}, [panel])
|
|
751
1032
|
|
|
1033
|
+
// Closing a panel also clears the transient state it carried: the search
|
|
1034
|
+
// text (so reopening shows the full list) and the failure notice.
|
|
1035
|
+
useEffect(() => {
|
|
1036
|
+
if (panel !== null) return
|
|
1037
|
+
setQuery('')
|
|
1038
|
+
setFailure(null)
|
|
1039
|
+
setNoticeOpen(false)
|
|
1040
|
+
}, [panel])
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Push a failure into the notice and collapse it again.
|
|
1044
|
+
*
|
|
1045
|
+
* @param result - the failed `select` result.
|
|
1046
|
+
*/
|
|
1047
|
+
const showFailure = useCallback((result) => {
|
|
1048
|
+
setFailure(failureOf(result, t))
|
|
1049
|
+
setNoticeOpen(false)
|
|
1050
|
+
}, [t])
|
|
1051
|
+
|
|
752
1052
|
/**
|
|
753
1053
|
* Choose a model, carrying the adapter's default effort when the caller did
|
|
754
|
-
* not name one — the shipped picker sends the same triple
|
|
755
|
-
*
|
|
1054
|
+
* not name one — the shipped picker sends the same triple.
|
|
1055
|
+
*
|
|
1056
|
+
* Returns the directory's own result unchanged: `{ ok: true }` or
|
|
1057
|
+
* `{ ok: false, error }`. Anything else would make a successful pick look
|
|
1058
|
+
* like a failure and leave the panel open.
|
|
756
1059
|
*/
|
|
757
1060
|
const select = useCallback(async (group, model, reasoningEffort) => {
|
|
758
|
-
if (
|
|
1061
|
+
if (typeof submit !== 'function') return undefined
|
|
1062
|
+
lastActionRef.current = 'select'
|
|
759
1063
|
const effort = reasoningEffort ?? model?.reasoning?.defaultEffort
|
|
760
|
-
|
|
1064
|
+
return submit({
|
|
761
1065
|
provider: group.id,
|
|
762
1066
|
model: model.id,
|
|
763
1067
|
...effort === undefined ? {} : { reasoningEffort: effort }
|
|
764
1068
|
})
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1069
|
+
}, [submit])
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Choosing a MODEL closes the panel on success.
|
|
1073
|
+
*
|
|
1074
|
+
* A rejected selection keeps it open and raises the notice instead, so the
|
|
1075
|
+
* user can pick another model without reopening the list.
|
|
1076
|
+
*/
|
|
1077
|
+
const chooseModel = useCallback(async (group, model, reasoningEffort) => {
|
|
1078
|
+
const result = await select(group, model, reasoningEffort)
|
|
1079
|
+
if (result?.ok === true) {
|
|
1080
|
+
setFailure(null)
|
|
1081
|
+
setPanel(null)
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
showFailure(result)
|
|
1085
|
+
}, [select, showFailure])
|
|
1086
|
+
|
|
1087
|
+
const queryText = query.trim()
|
|
1088
|
+
/**
|
|
1089
|
+
* The model list, ranked exactly like the shipped menus.
|
|
1090
|
+
*
|
|
1091
|
+
* `rankByName` is the host's own fuzzy matcher: an ordered subsequence of
|
|
1092
|
+
* the name (or of the model id, supplied as the second search key), prefix
|
|
1093
|
+
* hits first. A plain substring test could not find `gpt61` in
|
|
1094
|
+
* `GPT-6.1-Sol`, which is most of the point of having a search box.
|
|
1095
|
+
*/
|
|
1096
|
+
const filtered = useMemo(() => groups
|
|
1097
|
+
.map((group) => ({
|
|
1098
|
+
group,
|
|
1099
|
+
models: rankByName(
|
|
1100
|
+
(group.models ?? []).map((model) => ({
|
|
1101
|
+
...model,
|
|
1102
|
+
label: typeof model.label === 'string' ? model.label : model.id
|
|
1103
|
+
})),
|
|
1104
|
+
queryText
|
|
1105
|
+
)
|
|
1106
|
+
}))
|
|
1107
|
+
.filter((entry) => entry.models.length > 0), [groups, queryText])
|
|
778
1108
|
|
|
779
1109
|
const current = selection === null ? null : groups
|
|
780
1110
|
.find((group) => group.id === selection.provider)?.models
|
|
781
1111
|
?.find((model) => model.id === selection.model)
|
|
782
1112
|
const currentGroup = selection === null ? null : groups.find((group) => group.id === selection.provider) ?? null
|
|
783
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* A level change keeps the panel open, and moves the thumb IMMEDIATELY.
|
|
1116
|
+
*
|
|
1117
|
+
* Two things were wrong before:
|
|
1118
|
+
*
|
|
1119
|
+
* - Closing the panel on success made it vanish mid-drag, so the animation
|
|
1120
|
+
* never played — a level is a dial, not a commitment.
|
|
1121
|
+
* - The thumb was positioned from the directory's `current`, which only
|
|
1122
|
+
* updates after the Host answers. During a drag that means the thumb is
|
|
1123
|
+
* dragged back to the old stop on every render — it looks stuck to the
|
|
1124
|
+
* start and refuses to follow the pointer.
|
|
1125
|
+
*
|
|
1126
|
+
* `draftEffort` is the optimistic stop: set it on every move so the thumb
|
|
1127
|
+
* tracks the pointer, and clear it once the Host confirms so the directory
|
|
1128
|
+
* becomes the source of truth again. If the RPC fails the draft is dropped,
|
|
1129
|
+
* the thumb springs back to the real value, and the notice says why.
|
|
1130
|
+
*/
|
|
1131
|
+
const chooseEffort = useCallback(async (effortId) => {
|
|
1132
|
+
if (currentGroup === null || current === null) return
|
|
1133
|
+
setDraftEffort(effortId)
|
|
1134
|
+
const result = await select(currentGroup, current, effortId)
|
|
1135
|
+
if (result?.ok === true) {
|
|
1136
|
+
setFailure(null)
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
setDraftEffort(null)
|
|
1140
|
+
showFailure(result)
|
|
1141
|
+
}, [select, currentGroup, current, showFailure])
|
|
1142
|
+
|
|
784
1143
|
/**
|
|
785
1144
|
* What the trigger says, in the shipped picker's precedence.
|
|
786
1145
|
*
|
|
@@ -802,25 +1161,79 @@ function createModelPicker(React) {
|
|
|
802
1161
|
* will use, so that is what the control shows rather than a blank.
|
|
803
1162
|
*/
|
|
804
1163
|
const efforts = Array.isArray(current?.reasoning?.efforts) ? current.reasoning.efforts : []
|
|
805
|
-
|
|
1164
|
+
/**
|
|
1165
|
+
* The level to show, and why it is NOT simply `selection.reasoningEffort`.
|
|
1166
|
+
*
|
|
1167
|
+
* `selection.reasoningEffort` belongs to the SESSION, not to the model. When
|
|
1168
|
+
* the user switches to a model they have never used, that field still carries
|
|
1169
|
+
* the previous model's level — `xhigh` while the new model only declares
|
|
1170
|
+
* `low/high`, or `high` for both, where inheriting it silently starts the new
|
|
1171
|
+
* model at a level the user never chose for it.
|
|
1172
|
+
*
|
|
1173
|
+
* The rule is therefore: a level is only "current" for a model that has
|
|
1174
|
+
* already been used in this session. On a model switch the adapter's declared
|
|
1175
|
+
* default takes over, which is what the Host would use anyway. `modelKey`
|
|
1176
|
+
* tracks which model the level belongs to, so a switch is detectable.
|
|
1177
|
+
*/
|
|
1178
|
+
const declares = (id) => id !== undefined && id !== null && efforts.some((effort) => effort.id === id)
|
|
1179
|
+
const modelKey = current === null ? null : `${selection?.provider ?? ''}:${selection?.model ?? ''}`
|
|
1180
|
+
const seenModelRef = useRef(null)
|
|
1181
|
+
const switchedModel = seenModelRef.current !== null && seenModelRef.current !== modelKey
|
|
1182
|
+
const inheritedEffortId = switchedModel ? undefined : selection?.reasoningEffort
|
|
1183
|
+
/**
|
|
1184
|
+
* The level in effect, resolved through three fallbacks.
|
|
1185
|
+
*
|
|
1186
|
+
* A provider may declare `reasoningEfforts` without a default — the
|
|
1187
|
+
* `openai-responses` models in this profile do exactly that (five levels, no
|
|
1188
|
+
* default). Taking `defaultEffort` verbatim then left `activeEffortId`
|
|
1189
|
+
* undefined, so the tag rendered with an empty label even though the slider
|
|
1190
|
+
* had five stops. The first declared level is the right fallback: it is what
|
|
1191
|
+
* an unspecified request uses.
|
|
1192
|
+
*/
|
|
1193
|
+
const confirmedEffortId = declares(inheritedEffortId)
|
|
1194
|
+
? inheritedEffortId
|
|
1195
|
+
: declares(current?.reasoning?.defaultEffort)
|
|
1196
|
+
? current.reasoning.defaultEffort
|
|
1197
|
+
: efforts[0]?.id
|
|
1198
|
+
useEffect(() => { seenModelRef.current = modelKey }, [modelKey])
|
|
1199
|
+
const activeEffortId = draftEffort !== null && declares(draftEffort)
|
|
1200
|
+
? draftEffort
|
|
1201
|
+
: confirmedEffortId
|
|
806
1202
|
const activeEffort = efforts.find((effort) => effort.id === activeEffortId) ?? null
|
|
807
|
-
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* What the trigger prints for the current level.
|
|
1206
|
+
*
|
|
1207
|
+
* The trigger is only rendered when the model declares levels, so a resolved
|
|
1208
|
+
* name always exists by then; the id is a fallback for a level whose entry has
|
|
1209
|
+
* no display name, and an empty string is the last resort.
|
|
1210
|
+
*/
|
|
1211
|
+
const effortLabel = activeEffort?.name ?? activeEffort?.id ?? String(activeEffortId ?? '')
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Drop the draft once the Host agrees with it.
|
|
1215
|
+
*
|
|
1216
|
+
* Until the directory catches up the draft IS the truth, so it must not be
|
|
1217
|
+
* cleared earlier or the thumb would jump back for one frame. When the
|
|
1218
|
+
* confirmed value finally matches, the draft has done its job.
|
|
1219
|
+
*/
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
if (draftEffort !== null && confirmedEffortId === draftEffort) setDraftEffort(null)
|
|
1222
|
+
}, [draftEffort, confirmedEffortId])
|
|
808
1223
|
|
|
809
1224
|
/**
|
|
810
1225
|
* The label, in the shipped picker's addressable wrapper.
|
|
811
1226
|
*
|
|
812
|
-
* The `data-provider-id` / `data-model-id` span is kept because CSS that
|
|
813
|
-
* targets one model by id still works.
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
1227
|
+
* The `data-provider-id` / `data-model-id` span is kept because host CSS that
|
|
1228
|
+
* targets one model by id still works. It is deliberately plain text: this entry
|
|
1229
|
+
* does not declare the `conversation.input.model.label` child, so the renderer
|
|
1230
|
+
* hands it no `renderSlot` and provider badges cannot mount here (see AGENTS.md,
|
|
1231
|
+
* invariant 2 — declaring that child breaks the whole registration).
|
|
817
1232
|
*/
|
|
818
|
-
const renderLabel = (group, model
|
|
1233
|
+
const renderLabel = (group, model) => h('span', {
|
|
819
1234
|
'data-provider-id': group?.id,
|
|
820
1235
|
'data-model-id': model?.id
|
|
821
|
-
},
|
|
822
|
-
? model?.name
|
|
823
|
-
: renderSlot(LABEL_SLOT, { provider: group, model, placement, selected }, { fallback: model?.name }))
|
|
1236
|
+
}, model?.name)
|
|
824
1237
|
|
|
825
1238
|
const renderItem = (group, model) => {
|
|
826
1239
|
const isCurrent = selection !== null &&
|
|
@@ -831,9 +1244,9 @@ function createModelPicker(React) {
|
|
|
831
1244
|
role: 'option',
|
|
832
1245
|
'aria-selected': isCurrent,
|
|
833
1246
|
className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
|
|
834
|
-
onClick: () => void
|
|
1247
|
+
onClick: () => void chooseModel(group, model)
|
|
835
1248
|
},
|
|
836
|
-
h('span', { className: 'dmp-item-name' }, renderLabel(group, model
|
|
1249
|
+
h('span', { className: 'dmp-item-name' }, renderLabel(group, model)))
|
|
837
1250
|
}
|
|
838
1251
|
|
|
839
1252
|
const renderGroup = ({ group, models }) => h('div', { key: group.id },
|
|
@@ -842,24 +1255,23 @@ function createModelPicker(React) {
|
|
|
842
1255
|
|
|
843
1256
|
const notes = []
|
|
844
1257
|
/**
|
|
845
|
-
*
|
|
1258
|
+
* Catalogue problems only.
|
|
846
1259
|
*
|
|
847
|
-
* A
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* failed switch erased the load error and vice versa.
|
|
1260
|
+
* A selection failure is reported by the notice above the panels instead: it
|
|
1261
|
+
* belongs to this session, and the raw `code: message` text is not worth
|
|
1262
|
+
* pushing into the list. `lastActionRef` keeps a selection error from
|
|
1263
|
+
* appearing here as if it were a load failure.
|
|
852
1264
|
*/
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
if (state.status === 'loading' && groups.length === 0
|
|
1265
|
+
const loadError = state.status === 'error' && lastActionRef.current === 'load' &&
|
|
1266
|
+
typeof state.error === 'string' && state.error.length > 0 ? state.error : ''
|
|
1267
|
+
if (state.status === 'loading' && groups.length === 0) {
|
|
856
1268
|
notes.push(h('div', { key: 'loading', className: 'dmp-note' }, t.loading))
|
|
857
1269
|
}
|
|
858
|
-
if (
|
|
1270
|
+
if (loadError.length > 0) {
|
|
859
1271
|
notes.push(h('div', { key: 'error', className: 'dmp-note dmp-note-error' },
|
|
860
|
-
|
|
1272
|
+
loadError,
|
|
861
1273
|
h('div', null, h('button', {
|
|
862
|
-
type: 'button', className: 'dmp-retry', onClick:
|
|
1274
|
+
type: 'button', className: 'dmp-retry', onClick: doLoad
|
|
863
1275
|
}, t.retry))))
|
|
864
1276
|
}
|
|
865
1277
|
// A provider group whose catalogue failed: say so, instead of leaving a
|
|
@@ -873,40 +1285,114 @@ function createModelPicker(React) {
|
|
|
873
1285
|
notes.push(h('div', { key: 'empty', className: 'dmp-note' }, t.empty))
|
|
874
1286
|
}
|
|
875
1287
|
|
|
876
|
-
const
|
|
1288
|
+
const hasEfforts = efforts.length > 0
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Where each popover sits.
|
|
1292
|
+
*
|
|
1293
|
+
* Both panels are wider than their trigger and open above it, so a fixed side
|
|
1294
|
+
* anchor drifts away from the button and runs off the window near an edge.
|
|
1295
|
+
* The shared hook centres each panel on its trigger and clamps the result to
|
|
1296
|
+
* the viewport; `deps` re-measures when the content width changes (a five-level
|
|
1297
|
+
* slider is wider than a three-level one, and a longer model list is wider than
|
|
1298
|
+
* a short one).
|
|
1299
|
+
*/
|
|
1300
|
+
const effortAnchor = createAnchoredPanel(React, {
|
|
1301
|
+
open: panel === 'effort',
|
|
1302
|
+
fallbackWidth: 280,
|
|
1303
|
+
deps: [efforts.length]
|
|
1304
|
+
})
|
|
1305
|
+
const modelAnchor = createAnchoredPanel(React, {
|
|
1306
|
+
open: panel === 'model',
|
|
1307
|
+
fallbackWidth: 320,
|
|
1308
|
+
deps: [filtered.length, groups.length]
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Measure the notice so the open panel can sit above it instead of under it.
|
|
1313
|
+
*
|
|
1314
|
+
* The panel's `bottom` adds `--dmp-notice-lift`, so this only has to report
|
|
1315
|
+
* the notice's rendered height.
|
|
1316
|
+
*/
|
|
1317
|
+
useEffect(() => {
|
|
1318
|
+
const node = noticeRef.current
|
|
1319
|
+
if (failure === null || node === null || typeof node.getBoundingClientRect !== 'function') {
|
|
1320
|
+
setNoticeLift(0)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
const height = node.getBoundingClientRect().height
|
|
1324
|
+
setNoticeLift(Number.isFinite(height) && height > 0 ? height + 6 : 0)
|
|
1325
|
+
}, [failure, noticeOpen])
|
|
1326
|
+
|
|
1327
|
+
const notice = failure === null
|
|
1328
|
+
? null
|
|
1329
|
+
: h('div', { className: 'dmp-notice', ref: noticeRef, role: 'status' },
|
|
1330
|
+
h('button', {
|
|
1331
|
+
type: 'button',
|
|
1332
|
+
className: 'dmp-notice-toggle',
|
|
1333
|
+
'aria-expanded': noticeOpen,
|
|
1334
|
+
title: failure.detail,
|
|
1335
|
+
onClick: () => setNoticeOpen((value) => !value)
|
|
1336
|
+
},
|
|
1337
|
+
h('span', { className: 'dmp-notice-text' }, failure.summary),
|
|
1338
|
+
h(Chevron, { h, open: noticeOpen })),
|
|
1339
|
+
noticeOpen && failure.detail.length > 0
|
|
1340
|
+
? h('div', { className: 'dmp-notice-detail' }, failure.detail)
|
|
1341
|
+
: null)
|
|
1342
|
+
|
|
1343
|
+
const effortButton = current === null || currentGroup === null || !hasEfforts
|
|
877
1344
|
? null
|
|
878
1345
|
: h('button', {
|
|
879
1346
|
type: 'button',
|
|
1347
|
+
ref: effortAnchor.ref,
|
|
880
1348
|
className: 'dmp-trigger dmp-trigger-effort',
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
'
|
|
1349
|
+
// Only a locked composer disables the level tag. Its own commits flip the
|
|
1350
|
+
// directory to `selecting`, and disabling here killed the drag after the
|
|
1351
|
+
// first stop — the same reason the slider's range input stays enabled.
|
|
1352
|
+
disabled: locked === true,
|
|
1353
|
+
title: t.effortTitle(effortLabel),
|
|
1354
|
+
'aria-haspopup': 'dialog',
|
|
884
1355
|
'aria-expanded': panel === 'effort',
|
|
885
1356
|
onClick: () => setPanel((value) => (value === 'effort' ? null : 'effort'))
|
|
886
1357
|
},
|
|
887
|
-
h('span', { className: 'dmp-trigger-text' },
|
|
1358
|
+
h('span', { className: 'dmp-trigger-text dmp-trigger-effort-text' }, effortLabel),
|
|
888
1359
|
h(Chevron, { h, open: panel === 'effort' }))
|
|
889
1360
|
|
|
890
1361
|
const effortPanel = panel === 'effort' && effortButton !== null
|
|
891
|
-
? h('div', {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1362
|
+
? h('div', {
|
|
1363
|
+
className: 'dmp-panel dmp-panel-slider',
|
|
1364
|
+
role: 'dialog',
|
|
1365
|
+
'aria-label': t.effortHeading,
|
|
1366
|
+
ref: effortAnchor.panelRef,
|
|
1367
|
+
style: effortAnchor.style
|
|
1368
|
+
},
|
|
1369
|
+
h(EffortSlider, {
|
|
1370
|
+
efforts,
|
|
1371
|
+
activeId: activeEffortId,
|
|
1372
|
+
/**
|
|
1373
|
+
* Deliberately NOT disabled while a change is settling.
|
|
1374
|
+
*
|
|
1375
|
+
* `busy` tracks the directory's `status === 'selecting'`, and every stop
|
|
1376
|
+
* the slider passes submits a selection — so the first stop flipped
|
|
1377
|
+
* `busy`, disabled the input mid-gesture, and the drag died. The user
|
|
1378
|
+
* then had to press again for each further stop. A level is a dial: it
|
|
1379
|
+
* stays live, and the draft value keeps the thumb honest while the Host
|
|
1380
|
+
* catches up.
|
|
1381
|
+
*/
|
|
1382
|
+
disabled: locked === true,
|
|
1383
|
+
caption: glyphCaption ? undefined : t.effortHeading,
|
|
1384
|
+
t,
|
|
1385
|
+
onChange: (effortId) => { void chooseEffort(effortId) }
|
|
1386
|
+
}))
|
|
906
1387
|
: null
|
|
907
1388
|
|
|
908
1389
|
const modelPanel = panel === 'model'
|
|
909
|
-
? h('div', {
|
|
1390
|
+
? h('div', {
|
|
1391
|
+
className: 'dmp-panel',
|
|
1392
|
+
role: 'listbox',
|
|
1393
|
+
ref: modelAnchor.panelRef,
|
|
1394
|
+
style: modelAnchor.style
|
|
1395
|
+
},
|
|
910
1396
|
h('div', { className: 'dmp-search' },
|
|
911
1397
|
h('input', {
|
|
912
1398
|
type: 'search',
|
|
@@ -928,7 +1414,11 @@ function createModelPicker(React) {
|
|
|
928
1414
|
*/
|
|
929
1415
|
if (available === false) return null
|
|
930
1416
|
|
|
931
|
-
return h('div', {
|
|
1417
|
+
return h('div', {
|
|
1418
|
+
className: 'dmp-root',
|
|
1419
|
+
ref: rootRef,
|
|
1420
|
+
style: noticeLift > 0 ? { '--dmp-notice-lift': `${noticeLift}px` } : undefined
|
|
1421
|
+
},
|
|
932
1422
|
h('style', null, CSS),
|
|
933
1423
|
// The model control and the reasoning-level control are deliberately two
|
|
934
1424
|
// buttons: they are two independent choices, and a model change must not
|
|
@@ -936,6 +1426,7 @@ function createModelPicker(React) {
|
|
|
936
1426
|
h('div', { className: 'dmp-control' },
|
|
937
1427
|
h('button', {
|
|
938
1428
|
type: 'button',
|
|
1429
|
+
ref: modelAnchor.ref,
|
|
939
1430
|
className: 'dmp-trigger',
|
|
940
1431
|
disabled: locked === true || busy,
|
|
941
1432
|
title: t.trigger(currentLabel),
|
|
@@ -948,10 +1439,11 @@ function createModelPicker(React) {
|
|
|
948
1439
|
? t.switching
|
|
949
1440
|
: (current === null || currentGroup === null
|
|
950
1441
|
? currentLabel
|
|
951
|
-
: renderLabel(currentGroup, current
|
|
1442
|
+
: renderLabel(currentGroup, current))),
|
|
952
1443
|
h(Chevron, { h, open: panel === 'model' })),
|
|
953
1444
|
modelPanel),
|
|
954
|
-
effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel)
|
|
1445
|
+
effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel),
|
|
1446
|
+
notice)
|
|
955
1447
|
}
|
|
956
1448
|
}
|
|
957
1449
|
|
|
@@ -959,40 +1451,34 @@ function createModelPicker(React) {
|
|
|
959
1451
|
/**
|
|
960
1452
|
* Client entry: the module-loader registration and the plugin object.
|
|
961
1453
|
*
|
|
962
|
-
* This
|
|
963
|
-
*
|
|
964
|
-
*
|
|
1454
|
+
* This plugin replaces ONLY the composer's model-selection UI. Everything behind
|
|
1455
|
+
* it — the per-session directory, the catalogue cache, the invalidation events,
|
|
1456
|
+
* the selection RPC — is the shipped `ui-model-selection` implementation, reached
|
|
1457
|
+
* through its `modelDirectories` service.
|
|
965
1458
|
*
|
|
966
|
-
*
|
|
967
|
-
* `
|
|
968
|
-
*
|
|
969
|
-
*
|
|
1459
|
+
* That split works because the shipped plugin keeps its slot registration and its
|
|
1460
|
+
* component apart: the registration's `inject` hands the component a plain props
|
|
1461
|
+
* bundle (`available`, a `directory` store, `load`, `select`), and the component
|
|
1462
|
+
* may render however it likes. This entry registers the same slot with the same
|
|
1463
|
+
* `inject` contract and substitutes its own component.
|
|
970
1464
|
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* registration. The renderer then hands an entry a `renderSlot` binding only for
|
|
976
|
-
* children that entry declared itself, so not declaring means not rendering. The
|
|
977
|
-
* decorations therefore do not appear here and each model is shown by its plain
|
|
978
|
-
* name. That is an accepted consequence of shadowing this slot, not an oversight.
|
|
1465
|
+
* Why not copy the shipped bundle instead: a copy registers a second
|
|
1466
|
+
* `ModelDirectoryResolver` under the same service name, the two fight over it,
|
|
1467
|
+
* and the shipped plugin parks in `loading` — which fails the whole renderer
|
|
1468
|
+
* boot. Reusing the service avoids that, and keeps tracking upstream fixes.
|
|
979
1469
|
*/
|
|
980
1470
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
1471
|
const PLUGIN = 'dsh-model-picker'
|
|
985
1472
|
const SLOT = 'conversation.input.model'
|
|
986
|
-
/** The shipped
|
|
1473
|
+
/** The service the shipped picker exposes for per-session model directories. */
|
|
987
1474
|
const MODEL_DIRECTORIES = 'modelDirectories'
|
|
988
1475
|
/**
|
|
989
1476
|
* BELOW the shipped occupant's `priority: 0`.
|
|
990
1477
|
*
|
|
991
1478
|
* For a `single` slot the LOWEST priority renders — the registry says so in its
|
|
992
1479
|
* own error text ("register at a different priority to shadow it (lowest
|
|
993
|
-
* renders)")
|
|
994
|
-
*
|
|
995
|
-
* instead of shadowing it.
|
|
1480
|
+
* renders)"). A larger number would lose to the shipped picker instead of
|
|
1481
|
+
* shadowing it.
|
|
996
1482
|
*/
|
|
997
1483
|
const SHADOW_PRIORITY = -1
|
|
998
1484
|
|
|
@@ -1001,17 +1487,12 @@ const SHADOW_PRIORITY = -1
|
|
|
1001
1487
|
*
|
|
1002
1488
|
* The packaged desktop app has no devtools, so a client half that fails to
|
|
1003
1489
|
* register is indistinguishable from one that never loaded. Each stage is posted
|
|
1004
|
-
* to the Host half, which logs it
|
|
1005
|
-
*
|
|
1490
|
+
* to the Host half, which logs it beside its own line: the pair tells the two
|
|
1491
|
+
* cases apart immediately.
|
|
1006
1492
|
*
|
|
1007
1493
|
* @param stage - a short description of the load stage reached.
|
|
1008
1494
|
*/
|
|
1009
1495
|
const report = (stage) => {
|
|
1010
|
-
// The renderer console reaches the desktop app log, which is captured in every
|
|
1011
|
-
// diagnostics bundle. A POST to the Host half is kept as the richer channel,
|
|
1012
|
-
// but it cannot be the only one: it is a second thing that can fail (trust
|
|
1013
|
-
// fence, no route yet, offline), and a missing report then looks exactly like
|
|
1014
|
-
// "apply was never called".
|
|
1015
1496
|
try {
|
|
1016
1497
|
console.error(`[model-picker] ${stage}`)
|
|
1017
1498
|
} catch {
|
|
@@ -1032,8 +1513,7 @@ const report = (stage) => {
|
|
|
1032
1513
|
/**
|
|
1033
1514
|
* The failure worth capturing happens *outside* apply: the client composition
|
|
1034
1515
|
* builds this plugin object, then never applies it, and whatever it threw on the
|
|
1035
|
-
* way is only visible in the page
|
|
1036
|
-
* devtools for. Both global hooks forward to the host log instead.
|
|
1516
|
+
* way is only visible in the page.
|
|
1037
1517
|
*
|
|
1038
1518
|
* @param value - the thrown value, in any shape.
|
|
1039
1519
|
* @returns a short description safe to log.
|
|
@@ -1070,184 +1550,153 @@ window.__ModuleLoader__.load({
|
|
|
1070
1550
|
return {
|
|
1071
1551
|
name: PLUGIN,
|
|
1072
1552
|
/**
|
|
1073
|
-
*
|
|
1553
|
+
* The services this half reads, PLUS the ones the shipped directory service
|
|
1554
|
+
* reads on its own behalf.
|
|
1555
|
+
*
|
|
1556
|
+
* `remote` and `remote.session` look unused here — this file never mentions
|
|
1557
|
+
* them — but `models.directoryFor(sessionId)` calls into the shipped
|
|
1558
|
+
* resolver, and that call runs in THIS entry's fiber. Cordis only exposes a
|
|
1559
|
+
* service to a fiber that declares it, so dropping them makes every
|
|
1560
|
+
* `directoryFor` throw
|
|
1074
1561
|
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1562
|
+
* cannot get property "remote.session" without inject
|
|
1563
|
+
*
|
|
1564
|
+
* from inside `inject`. The renderer catches that, retires the entry and
|
|
1565
|
+
* silently paints the shipped picker — the plugin "just disappears".
|
|
1566
|
+
* Declaring what the callee needs is part of calling it, so they stay.
|
|
1080
1567
|
*/
|
|
1568
|
+
inject: ['locale', 'sessions', 'slots', 'remote', 'remote.session'],
|
|
1081
1569
|
apply(ctx) {
|
|
1082
1570
|
report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
|
|
1083
1571
|
|
|
1084
1572
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
* service LAZILY.
|
|
1087
|
-
*
|
|
1088
|
-
* Two facts pull in opposite directions and this is the seam between
|
|
1089
|
-
* them:
|
|
1090
|
-
*
|
|
1091
|
-
* - Event subscriptions must be installed from the plugin's own `apply`
|
|
1092
|
-
* fiber. The gateway registers a forwarded-event listener in the
|
|
1093
|
-
* CALLING fiber's private event domain, and a subscription made from a
|
|
1094
|
-
* `ctx.inject` callback lands in a different domain: `$on` returns a
|
|
1095
|
-
* disposer, so it looks successful, but the listener never fires.
|
|
1096
|
-
* - `remote` is frequently NOT visible yet when `apply` runs. Capturing
|
|
1097
|
-
* `ctx.get('remote')` here froze `undefined`, and every catalogue load
|
|
1098
|
-
* then answered "modelCatalog is unavailable".
|
|
1099
|
-
*
|
|
1100
|
-
* So the catalogue holds a resolver, not the face, and the subscription
|
|
1101
|
-
* is attached as soon as the service appears.
|
|
1102
|
-
*/
|
|
1103
|
-
const remoteFace = () => {
|
|
1104
|
-
try { return ctx.get('remote') } catch { return undefined }
|
|
1105
|
-
}
|
|
1106
|
-
const catalog = createCatalog(remoteFace, (message) => { report(message) })
|
|
1107
|
-
/**
|
|
1108
|
-
* Attach the invalidation listeners, retrying once the remote exists.
|
|
1573
|
+
* Register the seat through the shipped directory service.
|
|
1109
1574
|
*
|
|
1110
|
-
* `ctx.inject` runs
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1575
|
+
* `ctx.inject` runs this callback once the named services are visible, so
|
|
1576
|
+
* the registration lands at the same point the shipped one would. It is
|
|
1577
|
+
* also re-run if a service goes away and returns, which is what keeps the
|
|
1578
|
+
* seat from binding to `undefined` across a reconnect.
|
|
1114
1579
|
*/
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1580
|
+
// `remote` / `remote.session` are declared here as well as on the plugin:
|
|
1581
|
+
// this callback runs in the fiber that `ctx.inject` creates, and the
|
|
1582
|
+
// directory resolver uses those two services through that context.
|
|
1583
|
+
ctx.inject(['slots', MODEL_DIRECTORIES, 'remote', 'remote.session'], (scope) => {
|
|
1584
|
+
const slots = scope.get('slots')
|
|
1585
|
+
const sessions = scope.get('sessions')
|
|
1586
|
+
/**
|
|
1587
|
+
* The locale SERVICE, handed to the component as an observable source.
|
|
1588
|
+
*
|
|
1589
|
+
* Its snapshot is `{ active, locales, revision }` — NOT `{ locale }`.
|
|
1590
|
+
* Reading the wrong field made every UI string fall back to Chinese no
|
|
1591
|
+
* matter which language the shell was set to.
|
|
1592
|
+
*
|
|
1593
|
+
* It is passed through as a `{ getSnapshot, subscribe }` pair rather
|
|
1594
|
+
* than resolved to a string here: `inject` is cached per session
|
|
1595
|
+
* binding, so a string captured now would never follow a language
|
|
1596
|
+
* switch made while the composer is on screen.
|
|
1597
|
+
*/
|
|
1598
|
+
const locale = scope.get('locale')
|
|
1599
|
+
const localeSource = locale !== undefined &&
|
|
1600
|
+
typeof locale.getSnapshot === 'function' &&
|
|
1601
|
+
typeof locale.subscribe === 'function'
|
|
1602
|
+
? locale
|
|
1603
|
+
: undefined
|
|
1604
|
+
const initialLocale = localeSource?.getSnapshot?.()?.active
|
|
1605
|
+
const localeId = typeof initialLocale === 'string' && initialLocale.length > 0
|
|
1606
|
+
? initialLocale
|
|
1607
|
+
: 'zh-CN'
|
|
1608
|
+
|
|
1609
|
+
if (slots === undefined) {
|
|
1610
|
+
report('槽位未注册:slots 缺失')
|
|
1611
|
+
return
|
|
1129
1612
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
const slots = at.slots ?? ctx.get('slots')
|
|
1160
|
-
if (slots === undefined) {
|
|
1161
|
-
report('slots 服务仍不可用,槽位未注册')
|
|
1162
|
-
return
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Register the seat.
|
|
1616
|
+
*
|
|
1617
|
+
* Two details matter:
|
|
1618
|
+
*
|
|
1619
|
+
* - NO `children` table. The child slot `conversation.input.model.label`
|
|
1620
|
+
* is declared by the shipped occupant, and a child declaration is
|
|
1621
|
+
* EXCLUSIVE per slot key: `slots.register` throws
|
|
1622
|
+
* `slot "…label" is already declared (by …)` the moment a second entry
|
|
1623
|
+
* offers the same child. The throw escapes the slot callback and the
|
|
1624
|
+
* picker stops rendering. Disposing first does not help either — the
|
|
1625
|
+
* declaration belongs to the shipped entry, which never disposes.
|
|
1626
|
+
*
|
|
1627
|
+
* - The service lookup happens INSIDE the callback. `models` may not be
|
|
1628
|
+
* visible yet when `apply` runs; reading it here means the registration
|
|
1629
|
+
* waits for it instead of binding to undefined.
|
|
1630
|
+
*
|
|
1631
|
+
* The cost of dropping the child declaration is that provider badges
|
|
1632
|
+
* mounted at `conversation.input.model.label` no longer render here. That
|
|
1633
|
+
* is the same trade the reference plugin makes, and it is what makes the
|
|
1634
|
+
* registration survive.
|
|
1635
|
+
*/
|
|
1636
|
+
let disposeCurrent = null
|
|
1637
|
+
slots.inject(SLOT, () => {
|
|
1638
|
+
const models = scope.get(MODEL_DIRECTORIES)
|
|
1639
|
+
if (models === undefined) {
|
|
1640
|
+
report('modelDirectories 缺失,暂不注册')
|
|
1641
|
+
return () => {}
|
|
1163
1642
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
name: SLOT,
|
|
1197
|
-
priority: SHADOW_PRIORITY,
|
|
1198
|
-
inject: (sessionId) => {
|
|
1199
|
-
const sessionSessions = ctx.get('sessions')
|
|
1200
|
-
/**
|
|
1201
|
-
* The per-session directory is the data face of this picker.
|
|
1202
|
-
*
|
|
1203
|
-
* `directoryFor` is the shipped service's own entry point; when
|
|
1204
|
-
* this plugin had to provide the service, the fallback has the
|
|
1205
|
-
* same signature, so the component never knows the difference.
|
|
1206
|
-
*/
|
|
1207
|
-
const directory = directories.directoryFor(sessionId)
|
|
1208
|
-
// The shipped directory projects inside its constructor and on
|
|
1209
|
-
// its subscriptions; it exposes no `sync`. Only this plugin's
|
|
1210
|
-
// fallback has one, so probe instead of assuming — calling it
|
|
1211
|
-
// unconditionally threw on the shipped service and took the
|
|
1212
|
-
// whole slot registration down with it.
|
|
1213
|
-
if (typeof directory?.sync === 'function') directory.sync()
|
|
1643
|
+
// The callback can fire more than once (a service re-appearing re-runs
|
|
1644
|
+
// it). Registering twice at the same priority throws
|
|
1645
|
+
// `single slot "…" already has a registration`, so the previous one is
|
|
1646
|
+
// released first. Without a children table this is safe — the child
|
|
1647
|
+
// declaration is what cannot be released.
|
|
1648
|
+
if (disposeCurrent !== null) {
|
|
1649
|
+
try {
|
|
1650
|
+
disposeCurrent()
|
|
1651
|
+
} catch {
|
|
1652
|
+
// Already gone.
|
|
1653
|
+
}
|
|
1654
|
+
disposeCurrent = null
|
|
1655
|
+
}
|
|
1656
|
+
report('槽位回调已运行,准备注册 occupant')
|
|
1657
|
+
disposeCurrent = slots.register({
|
|
1658
|
+
name: SLOT,
|
|
1659
|
+
priority: SHADOW_PRIORITY,
|
|
1660
|
+
inject: (sessionId) => {
|
|
1661
|
+
const directory = models.directoryFor(sessionId)
|
|
1662
|
+
const available = typeof sessions?.subagentAddress === 'function'
|
|
1663
|
+
? sessions.subagentAddress(sessionId) === undefined
|
|
1664
|
+
: true
|
|
1665
|
+
return {
|
|
1666
|
+
localeId,
|
|
1667
|
+
localeSource,
|
|
1668
|
+
available,
|
|
1669
|
+
// The shipped registration hands over the STORE, not the
|
|
1670
|
+
// controller; the component subscribes to it directly.
|
|
1671
|
+
directory: directory.store,
|
|
1672
|
+
load: () => {
|
|
1673
|
+
if (available) directory.load().catch(() => {})
|
|
1674
|
+
},
|
|
1214
1675
|
/**
|
|
1215
|
-
*
|
|
1676
|
+
* Submit one triple.
|
|
1216
1677
|
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1678
|
+
* The RESULT IS PASSED THROUGH UNCHANGED. `ModelDirectory.select`
|
|
1679
|
+
* resolves to `{ ok: true, value }` or `{ ok: false, error }`,
|
|
1680
|
+
* and the component — like the shipped one — reads `ok` to
|
|
1681
|
+
* decide whether to close the panel and which failure to show.
|
|
1682
|
+
* Mapping it to a boolean here made every successful pick look
|
|
1683
|
+
* like a failure: the model switched but the list stayed open,
|
|
1684
|
+
* and a settled effort level dropped its optimistic draft.
|
|
1222
1685
|
*/
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
sessionId,
|
|
1227
|
-
localeId,
|
|
1228
|
-
remote: remoteFace(),
|
|
1229
|
-
remoteSession,
|
|
1230
|
-
sessions: sessionSessions,
|
|
1231
|
-
catalog,
|
|
1232
|
-
directory,
|
|
1233
|
-
available: isAvailable(sessionSessions, sessionId)
|
|
1234
|
-
}
|
|
1686
|
+
select: (selection) => available
|
|
1687
|
+
? directory.select(selection)
|
|
1688
|
+
: Promise.resolve(undefined)
|
|
1235
1689
|
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
// can no longer fail the boot. The remotes are included because the
|
|
1247
|
-
// picker cannot do anything without them, and waiting here keeps the
|
|
1248
|
-
// failure to a missing control instead of a crashed one.
|
|
1249
|
-
if (typeof ctx.inject === 'function') ctx.inject(['slots', 'remote', 'remote.session'], install)
|
|
1250
|
-
else install(ctx)
|
|
1690
|
+
}
|
|
1691
|
+
}, ModelPicker)
|
|
1692
|
+
return () => {
|
|
1693
|
+
if (disposeCurrent !== null) {
|
|
1694
|
+
try { disposeCurrent() } catch { /* already gone */ }
|
|
1695
|
+
disposeCurrent = null
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
})
|
|
1699
|
+
})
|
|
1251
1700
|
}
|
|
1252
1701
|
}
|
|
1253
1702
|
}
|