@lynn123411/dsh-model-submenu 0.3.3 → 0.3.10
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/DETAILS.md +60 -4
- package/README.md +12 -9
- package/lib/client.js +759 -629
- package/lib/client.js.map +1 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +20 -19
- package/src/client/EffortSlider.tsx +159 -0
- package/src/client/EffortTint.tsx +61 -0
- package/src/client/ModelMenuBridge.tsx +203 -0
- package/src/client/{ModelSubmenu.module.css → ModelOptions.module.css} +100 -227
- package/src/client/ModelPicker.tsx +21 -5
- package/src/client/ModelPickerHost.tsx +146 -0
- package/src/client/ModelRow.tsx +57 -95
- package/src/client/index.ts +65 -27
- package/src/client/level-color.ts +51 -0
- package/src/client/locales.ts +12 -33
- package/src/client/picker-state.ts +55 -0
- package/src/client/selection.ts +43 -17
- package/src/client/slider.ts +51 -0
- package/src/client/ModelSubmenu.tsx +0 -431
|
@@ -1,125 +1,123 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The reasoning-level slider, the picker, and the composer overlay that hosts
|
|
3
|
+
* the picker. The dialog renders through the primitives' Modal, so this file
|
|
4
|
+
* paints no floating fill — it only places content on the surface that
|
|
5
|
+
* component owns.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
display:
|
|
8
|
+
/* The overlay entry is only a mount point for the dialog and its toast; it must
|
|
9
|
+
* not take space inside the composer card. */
|
|
10
|
+
.pickerHost {
|
|
11
|
+
display: contents;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/*
|
|
15
|
-
* row owns both variables, so this plugin follows the same compact contract as
|
|
16
|
-
* the built-in occupant. */
|
|
17
|
-
.trigger {
|
|
18
|
-
min-width: 0;
|
|
19
|
-
max-width: min(360px, 45cqw);
|
|
20
|
-
height: 28px;
|
|
21
|
-
color: var(--dsw-alias-label-secondary);
|
|
22
|
-
cursor: pointer;
|
|
23
|
-
background: 0 0;
|
|
24
|
-
border: none;
|
|
25
|
-
border-radius: 24px;
|
|
26
|
-
outline: none;
|
|
27
|
-
align-items: center;
|
|
28
|
-
gap: 4px;
|
|
29
|
-
padding: 0 4px 0 8px;
|
|
30
|
-
font-size: 13px;
|
|
31
|
-
font-weight: 500;
|
|
32
|
-
line-height: 20px;
|
|
33
|
-
display: flex;
|
|
34
|
-
}
|
|
14
|
+
/* ── the reasoning-level slider ──────────────────────────────────────────── */
|
|
35
15
|
|
|
36
|
-
|
|
37
|
-
|
|
16
|
+
/* The Host's effort cell is a flex row: label, value, chevron. The slider takes
|
|
17
|
+
* the slack between the label and the value, so the cell keeps the Host's own
|
|
18
|
+
* metrics and the menu does not grow. */
|
|
19
|
+
.sliderCell {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex: 1 1 auto;
|
|
22
|
+
align-items: center;
|
|
23
|
+
align-self: stretch;
|
|
24
|
+
min-width: 96px;
|
|
38
25
|
}
|
|
39
26
|
|
|
40
|
-
.
|
|
41
|
-
|
|
27
|
+
.slider {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex: 1;
|
|
30
|
+
align-items: center;
|
|
31
|
+
height: 100%;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
outline: none;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
touch-action: none;
|
|
36
|
+
user-select: none;
|
|
37
|
+
-webkit-user-select: none;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
.
|
|
45
|
-
color: var(--dsw-alias-label-dimmed);
|
|
40
|
+
.slider[data-busy] {
|
|
46
41
|
cursor: default;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
44
|
+
.sliderTrack {
|
|
45
|
+
position: relative;
|
|
46
|
+
width: 100%;
|
|
47
|
+
height: 20px;
|
|
48
|
+
border-radius: 999px;
|
|
49
|
+
corner-shape: round;
|
|
50
|
+
background: var(--dsw-alias-border-l3);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
/* The
|
|
58
|
-
*
|
|
59
|
-
.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
/* The fill takes the level's color and is clipped to the filled part, so the
|
|
54
|
+
* color under the knob is the color the level's text takes. */
|
|
55
|
+
.sliderFill {
|
|
56
|
+
position: absolute;
|
|
57
|
+
top: 0;
|
|
58
|
+
bottom: 0;
|
|
59
|
+
left: 0;
|
|
60
|
+
border-radius: 999px;
|
|
61
|
+
corner-shape: round;
|
|
65
62
|
overflow: hidden;
|
|
66
|
-
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* One detent per level: the dot is the only thing that says the levels are
|
|
67
|
+
* discrete, so it stays visible on both sides of the knob. */
|
|
68
|
+
.sliderTick {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: 50%;
|
|
71
|
+
width: 4px;
|
|
72
|
+
height: 4px;
|
|
73
|
+
margin: -2px 0 0 -2px;
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
corner-shape: round;
|
|
76
|
+
background: var(--dsw-alias-label-tertiary);
|
|
77
|
+
pointer-events: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* On the filled part the detent goes dark: white would read as one more grain
|
|
81
|
+
* of stardust, and the dot has to stay the thing that says "levels". */
|
|
82
|
+
.sliderTick[data-on] {
|
|
83
|
+
background: rgba(0, 0, 0, 0.28);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sliderKnob {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 50%;
|
|
89
|
+
width: 20px;
|
|
90
|
+
height: 20px;
|
|
91
|
+
margin: -10px 0 0 -10px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
corner-shape: round;
|
|
94
|
+
background: var(--dsw-alias-label-primary-foreground);
|
|
95
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
96
|
+
pointer-events: none;
|
|
67
97
|
}
|
|
68
98
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
/* Provider default: nothing is filled and the knob is a hollow ring, so the
|
|
100
|
+
* state never reads as the weakest level. */
|
|
101
|
+
.slider[data-default] .sliderKnob {
|
|
102
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
103
|
+
border: 1.5px solid var(--dsw-alias-label-tertiary);
|
|
104
|
+
box-shadow: none;
|
|
72
105
|
}
|
|
73
106
|
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
flex: none;
|
|
77
|
-
transition: transform .12s;
|
|
107
|
+
.slider[data-busy] .sliderKnob {
|
|
108
|
+
opacity: 0.6;
|
|
78
109
|
}
|
|
79
110
|
|
|
80
|
-
.
|
|
81
|
-
|
|
111
|
+
.slider:focus-visible .sliderTrack {
|
|
112
|
+
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
113
|
+
outline-offset: 3px;
|
|
82
114
|
}
|
|
83
115
|
|
|
84
|
-
|
|
85
|
-
z-index: 1100;
|
|
86
|
-
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
87
|
-
/* A fixed width keeps the card independent of the trigger's text length, so
|
|
88
|
-
* opening the menu never reflows the composer. */
|
|
89
|
-
box-sizing: border-box;
|
|
90
|
-
width: min(252px, calc(100vw - 24px));
|
|
91
|
-
max-height: min(70vh, calc(100vh - 96px));
|
|
92
|
-
box-shadow: var(--dsw-elevation-prominent);
|
|
93
|
-
color: var(--dsw-alias-label-primary);
|
|
94
|
-
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
95
|
-
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
96
|
-
border: 0;
|
|
97
|
-
flex-direction: column;
|
|
98
|
-
padding: 4px;
|
|
99
|
-
display: flex;
|
|
100
|
-
position: fixed;
|
|
101
|
-
overflow: hidden;
|
|
102
|
-
}
|
|
116
|
+
/* ── the picker rows ─────────────────────────────────────────────────────── */
|
|
103
117
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
text-overflow: ellipsis;
|
|
108
|
-
white-space: nowrap;
|
|
109
|
-
min-height: 24px;
|
|
110
|
-
color: var(--dsw-alias-label-tertiary);
|
|
111
|
-
align-items: center;
|
|
112
|
-
padding: 3px 8px;
|
|
113
|
-
font-size: 12px;
|
|
114
|
-
font-weight: 500;
|
|
115
|
-
line-height: 18px;
|
|
116
|
-
overflow: hidden;
|
|
117
|
-
display: flex;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
.groups {
|
|
121
|
-
min-height: 0;
|
|
122
|
-
overflow-y: auto;
|
|
118
|
+
.rowBadge {
|
|
119
|
+
flex: none;
|
|
120
|
+
margin-inline-start: 6px;
|
|
123
121
|
}
|
|
124
122
|
|
|
125
123
|
.row {
|
|
@@ -146,7 +144,7 @@
|
|
|
146
144
|
cursor: pointer;
|
|
147
145
|
background: 0 0;
|
|
148
146
|
border: none;
|
|
149
|
-
border-radius:
|
|
147
|
+
border-radius: var(--dsw-radius-md);
|
|
150
148
|
outline: none;
|
|
151
149
|
align-items: center;
|
|
152
150
|
gap: 4px;
|
|
@@ -181,13 +179,6 @@
|
|
|
181
179
|
overflow: hidden;
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
/* The menu's names carry the weight: at this size a regular face reads thin
|
|
185
|
-
* against the card. The picker's rows keep theirs regular — there the metadata
|
|
186
|
-
* line already separates the rows. */
|
|
187
|
-
.menu .rowName {
|
|
188
|
-
font-weight: 600;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
182
|
.rowMeta {
|
|
192
183
|
color: var(--dsw-alias-label-tertiary);
|
|
193
184
|
text-overflow: ellipsis;
|
|
@@ -204,16 +195,6 @@
|
|
|
204
195
|
display: grid;
|
|
205
196
|
}
|
|
206
197
|
|
|
207
|
-
/* The menu's favorite mark: it lives inside the row button, so hovering it
|
|
208
|
-
* highlights the row and clicking it picks the model — no separate hit area. */
|
|
209
|
-
.starStatic {
|
|
210
|
-
color: var(--dsw-alias-state-warn-primary);
|
|
211
|
-
flex: none;
|
|
212
|
-
place-items: center;
|
|
213
|
-
width: 14px;
|
|
214
|
-
display: grid;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
198
|
/* The picker's star is a real control: outline until hover or focus, solid and
|
|
218
199
|
* gold once favorited, and visible without hover on touch screens. */
|
|
219
200
|
.starButton {
|
|
@@ -221,7 +202,7 @@
|
|
|
221
202
|
cursor: pointer;
|
|
222
203
|
background: 0 0;
|
|
223
204
|
border: none;
|
|
224
|
-
border-radius:
|
|
205
|
+
border-radius: var(--dsw-radius-sm);
|
|
225
206
|
outline: none;
|
|
226
207
|
flex: none;
|
|
227
208
|
place-items: center;
|
|
@@ -255,114 +236,7 @@
|
|
|
255
236
|
flex: none;
|
|
256
237
|
}
|
|
257
238
|
|
|
258
|
-
|
|
259
|
-
height: .5px;
|
|
260
|
-
background: var(--dsw-alias-border-l2);
|
|
261
|
-
flex: none;
|
|
262
|
-
margin: 3px 2px;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.effortRow {
|
|
266
|
-
align-items: center;
|
|
267
|
-
gap: 6px;
|
|
268
|
-
padding: 3px 6px;
|
|
269
|
-
display: flex;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
.effortHeading {
|
|
273
|
-
color: var(--dsw-alias-label-primary);
|
|
274
|
-
flex: none;
|
|
275
|
-
font-size: 12px;
|
|
276
|
-
line-height: 18px;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/* The segments scroll sideways rather than wrap, so the row keeps one height
|
|
280
|
-
* however many levels a model advertises. */
|
|
281
|
-
.effortTrack {
|
|
282
|
-
background: var(--dsw-alias-interactive-bg-hover);
|
|
283
|
-
border-radius: 999px;
|
|
284
|
-
align-items: center;
|
|
285
|
-
gap: 1px;
|
|
286
|
-
min-width: 0;
|
|
287
|
-
margin-left: auto;
|
|
288
|
-
padding: 2px;
|
|
289
|
-
display: flex;
|
|
290
|
-
overflow-x: auto;
|
|
291
|
-
scrollbar-width: none;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
.effortTrack::-webkit-scrollbar {
|
|
295
|
-
display: none;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
.effortSegment {
|
|
299
|
-
color: var(--dsw-alias-label-secondary);
|
|
300
|
-
cursor: pointer;
|
|
301
|
-
background: 0 0;
|
|
302
|
-
border: none;
|
|
303
|
-
border-radius: 999px;
|
|
304
|
-
outline: none;
|
|
305
|
-
flex: none;
|
|
306
|
-
padding: 2px 7px;
|
|
307
|
-
font-size: 11px;
|
|
308
|
-
line-height: 16px;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
.effortSegment:hover:not(:disabled) {
|
|
312
|
-
color: var(--dsw-alias-label-primary);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.effortSegment[aria-checked='true'] {
|
|
316
|
-
color: var(--dsw-alias-label-primary);
|
|
317
|
-
background: var(--dsw-alias-bg-layer-1);
|
|
318
|
-
box-shadow: var(--dsw-elevation-soft);
|
|
319
|
-
font-weight: 600;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
.effortSegment:disabled {
|
|
323
|
-
color: var(--dsw-alias-label-dimmed);
|
|
324
|
-
cursor: default;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
.effortSegment:focus-visible {
|
|
328
|
-
outline: 2px solid var(--dsw-alias-border-l3);
|
|
329
|
-
outline-offset: -2px;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
.hint {
|
|
333
|
-
color: var(--dsw-alias-label-tertiary);
|
|
334
|
-
margin: 0;
|
|
335
|
-
padding: 6px 8px;
|
|
336
|
-
font-size: 11px;
|
|
337
|
-
line-height: 16px;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
.moreRow {
|
|
341
|
-
color: var(--dsw-alias-label-secondary);
|
|
342
|
-
cursor: pointer;
|
|
343
|
-
background: 0 0;
|
|
344
|
-
border: none;
|
|
345
|
-
border-radius: 10px;
|
|
346
|
-
outline: none;
|
|
347
|
-
align-items: center;
|
|
348
|
-
gap: 6px;
|
|
349
|
-
min-height: 32px;
|
|
350
|
-
padding: 5px 8px;
|
|
351
|
-
font-size: 12px;
|
|
352
|
-
line-height: 18px;
|
|
353
|
-
display: flex;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
.moreRow:hover,
|
|
357
|
-
.moreRow:focus-visible {
|
|
358
|
-
color: var(--dsw-alias-label-primary);
|
|
359
|
-
background: var(--dsw-alias-interactive-bg-hover);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
.moreIcon {
|
|
363
|
-
color: var(--dsw-alias-label-tertiary);
|
|
364
|
-
flex: none;
|
|
365
|
-
}
|
|
239
|
+
/* ── shared status lines ─────────────────────────────────────────────────── */
|
|
366
240
|
|
|
367
241
|
.notice {
|
|
368
242
|
color: var(--dsw-alias-label-tertiary);
|
|
@@ -423,8 +297,7 @@
|
|
|
423
297
|
|
|
424
298
|
/* The tabs scroll sideways rather than wrap: the dialog keeps one row of
|
|
425
299
|
* providers however many the catalog has, so the list below starts at the same
|
|
426
|
-
* height every time. The bar keeps its hairline and hides its scrollbar
|
|
427
|
-
* the effort track does. */
|
|
300
|
+
* height every time. The bar keeps its hairline and hides its scrollbar. */
|
|
428
301
|
.pickerTabs {
|
|
429
302
|
border-bottom: .5px solid var(--dsw-alias-border-l2);
|
|
430
303
|
gap: 2px;
|
|
@@ -445,7 +318,7 @@
|
|
|
445
318
|
cursor: pointer;
|
|
446
319
|
background: 0 0;
|
|
447
320
|
border: none;
|
|
448
|
-
border-radius:
|
|
321
|
+
border-radius: var(--dsw-radius-sm);
|
|
449
322
|
outline: none;
|
|
450
323
|
flex: none;
|
|
451
324
|
padding: 6px 10px;
|
|
@@ -29,18 +29,22 @@ import {
|
|
|
29
29
|
reasoningOf,
|
|
30
30
|
rowsForTab,
|
|
31
31
|
searchRows,
|
|
32
|
+
type CatalogModel,
|
|
32
33
|
type ModelRow as CatalogRow,
|
|
33
34
|
type PickerTab,
|
|
34
35
|
} from './rows.ts'
|
|
36
|
+
import { Tag, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
35
37
|
import type { ModelFactsLookup } from './facts-source.ts'
|
|
36
38
|
import type { Selection } from './selection.ts'
|
|
37
39
|
import { planModelCommit } from './selection.ts'
|
|
38
|
-
import css from './
|
|
40
|
+
import css from './ModelOptions.module.css'
|
|
39
41
|
|
|
40
42
|
/** Props the seat hands the picker. */
|
|
41
43
|
export interface ModelPickerProps {
|
|
42
44
|
/** Whether the dialog is showing. */
|
|
43
45
|
readonly open: boolean
|
|
46
|
+
/** Resolve a catalog badge's localized text for the active language. */
|
|
47
|
+
readonly resolveText: (text: NonNullable<CatalogModel['badge']>['text']) => string
|
|
44
48
|
/** The shared directory snapshot. */
|
|
45
49
|
readonly state: ModelDirectoryState
|
|
46
50
|
/** The selection in force, null before a first selection resolves. */
|
|
@@ -51,6 +55,13 @@ export interface ModelPickerProps {
|
|
|
51
55
|
readonly favorites: readonly { readonly provider: string; readonly model: string }[]
|
|
52
56
|
/** Whether a selection is in flight. */
|
|
53
57
|
readonly busy: boolean
|
|
58
|
+
/**
|
|
59
|
+
* The reasoning level this browser last used for one model, if any.
|
|
60
|
+
* @param provider - the provider route key.
|
|
61
|
+
* @param model - the model id inside that provider.
|
|
62
|
+
* @returns the remembered level, or undefined when none is remembered.
|
|
63
|
+
*/
|
|
64
|
+
readonly rememberedOf?: (provider: string, model: string) => string | undefined
|
|
54
65
|
/** Submit one selection. */
|
|
55
66
|
readonly onPick: (selection: Selection) => void
|
|
56
67
|
/** Toggle one model's favorite. */
|
|
@@ -67,7 +78,7 @@ export interface ModelPickerProps {
|
|
|
67
78
|
* @returns the dialog; nothing while closed.
|
|
68
79
|
*/
|
|
69
80
|
export function ModelPicker({
|
|
70
|
-
open, state, live, facts, favorites, busy, onPick, onToggleFavorite, onReload, onClose, t,
|
|
81
|
+
open, resolveText, state, live, facts, favorites, busy, rememberedOf, onPick, onToggleFavorite, onReload, onClose, t,
|
|
71
82
|
}: ModelPickerProps & PropsLocale<'modelSubmenu'>) {
|
|
72
83
|
const id = useId()
|
|
73
84
|
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -97,7 +108,10 @@ export function ModelPicker({
|
|
|
97
108
|
setTab(ALL_TAB)
|
|
98
109
|
setHighlight(0)
|
|
99
110
|
if (listRef.current !== null) listRef.current.scrollTop = 0
|
|
100
|
-
|
|
111
|
+
// 粗指针设备不自动聚焦文本输入:移动端弹键盘会顶起布局,宿主座位也这样守。
|
|
112
|
+
const frame = requestAnimationFrame(() => {
|
|
113
|
+
if (!isMobileDevice()) searchRef.current?.focus()
|
|
114
|
+
})
|
|
101
115
|
return () => { cancelAnimationFrame(frame) }
|
|
102
116
|
}, [open])
|
|
103
117
|
|
|
@@ -130,7 +144,7 @@ export function ModelPicker({
|
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
const pick = (row: CatalogRow) => {
|
|
133
|
-
const selection = planModelCommit(live, row.provider, row.model, reason)
|
|
147
|
+
const selection = planModelCommit(live, row.provider, row.model, reason, rememberedOf?.(row.provider, row.model))
|
|
134
148
|
if (selection === null) {
|
|
135
149
|
onClose()
|
|
136
150
|
return
|
|
@@ -246,9 +260,11 @@ export function ModelPicker({
|
|
|
246
260
|
<div key={`${row.provider}/${row.model}`} data-row-index={index} id={`${id}-row-${index}`}>
|
|
247
261
|
<ModelRow
|
|
248
262
|
name={row.entry.name}
|
|
263
|
+
badge={row.entry.badge === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: <Tag tone={row.entry.badge.tone} className={css.rowBadge}>{resolveText(row.entry.badge.text)}</Tag>}
|
|
249
266
|
selected={live?.provider === row.provider && live.model === row.model}
|
|
250
267
|
busy={busy}
|
|
251
|
-
star="interactive"
|
|
252
268
|
favorited={isFavorite(favorites, row.provider, row.model)}
|
|
253
269
|
meta={metaLine({
|
|
254
270
|
row,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer overlay's picker view: the dialog, its catalog, and its verbs.
|
|
3
|
+
*
|
|
4
|
+
* It lives in `conversation.input.overlay` rather than beside the row, because
|
|
5
|
+
* the Host unmounts the model menu as soon as a row is clicked. Everything the
|
|
6
|
+
* dialog reads arrives through this entry's injected face.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
9
|
+
import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
10
|
+
import type { ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client';
|
|
11
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
|
|
12
|
+
import { ModelPicker } from './ModelPicker.tsx';
|
|
13
|
+
import type { FactsSource } from './facts-source.ts';
|
|
14
|
+
import {
|
|
15
|
+
readFavorites,
|
|
16
|
+
toggleFavorite,
|
|
17
|
+
type FavoriteModel,
|
|
18
|
+
type FavoritesStorage,
|
|
19
|
+
} from './favorites.ts';
|
|
20
|
+
import {
|
|
21
|
+
effortFor,
|
|
22
|
+
readEfforts,
|
|
23
|
+
rememberEffort,
|
|
24
|
+
type RememberedEffort,
|
|
25
|
+
} from './effort-memory.ts';
|
|
26
|
+
import { selectionOf, type Selection } from './selection.ts';
|
|
27
|
+
import type { PickerStore } from './picker-state.ts';
|
|
28
|
+
import css from './ModelOptions.module.css';
|
|
29
|
+
|
|
30
|
+
/** Props of the overlay view: the session, the store, and the seat's injected face. */
|
|
31
|
+
export type ModelPickerHostProps = PropsLocale<'modelSubmenu'>
|
|
32
|
+
& ModelSelectInjected
|
|
33
|
+
& {
|
|
34
|
+
/** Session whose catalog this view renders. */
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
/** The request written by the menu row. */
|
|
37
|
+
readonly picker: PickerStore;
|
|
38
|
+
/** The settings-derived metadata the picker's line reads. */
|
|
39
|
+
readonly factsSource: FactsSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render the picker for this session.
|
|
44
|
+
* @param props - session, store, catalog face, and localized copy.
|
|
45
|
+
* @returns the dialog while the store names this session; otherwise nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function ModelPickerHost({
|
|
48
|
+
sessionId, picker, available, directory, load, select, resolveText, factsSource, t,
|
|
49
|
+
}: ModelPickerHostProps) {
|
|
50
|
+
const request = useSyncExternalStore(picker.subscribe, picker.getSnapshot);
|
|
51
|
+
const state = useSyncExternalStore((listener) => directory.subscribe(listener), () => directory.getSnapshot());
|
|
52
|
+
const [favorites, setFavorites] = useState<readonly FavoriteModel[]>(() => readFavorites(storageOf()));
|
|
53
|
+
const [memory, setMemory] = useState<readonly RememberedEffort[]>(() => readEfforts(storageOf()));
|
|
54
|
+
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null);
|
|
55
|
+
const toastSeq = useRef(0);
|
|
56
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
|
|
58
|
+
const busy = state.status === 'selecting';
|
|
59
|
+
const live = useMemo(() => selectionOf(state), [state]);
|
|
60
|
+
const facts = useSyncExternalStore((listener) => factsSource.subscribe(listener), () => factsSource.getSnapshot());
|
|
61
|
+
const open = request.sessionId === sessionId;
|
|
62
|
+
// The injected face is cached per entry and session, but the read goes through
|
|
63
|
+
// a ref anyway: a catalog load writes the store, so an unstable `load` in the
|
|
64
|
+
// dependency list would reload on every render it caused.
|
|
65
|
+
const loadRef = useRef(load);
|
|
66
|
+
loadRef.current = load;
|
|
67
|
+
|
|
68
|
+
// Opening reads the catalog again: another client may have changed it, and a
|
|
69
|
+
// failed load has to be retryable from the same gesture.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (open) loadRef.current();
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
// The Host's own menu changes the reasoning level without passing through this
|
|
75
|
+
// plugin, so the memory is written from the directory snapshot too — a picker
|
|
76
|
+
// click alone would only ever record levels chosen in the dialog. The key guard
|
|
77
|
+
// keeps one write per distinct selection instead of one per snapshot.
|
|
78
|
+
const current = state.current;
|
|
79
|
+
const rememberedRef = useRef<string | null>(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (current === null) return;
|
|
82
|
+
const key = `${current.provider}/${current.model}/${current.reasoningEffort ?? ''}`;
|
|
83
|
+
if (rememberedRef.current === key) return;
|
|
84
|
+
rememberedRef.current = key;
|
|
85
|
+
setMemory(rememberEffort(storageOf(), current.provider, current.model, current.reasoningEffort));
|
|
86
|
+
}, [current]);
|
|
87
|
+
|
|
88
|
+
if (!available) return null;
|
|
89
|
+
|
|
90
|
+
const raiseToast = (text: string): void => {
|
|
91
|
+
toastSeq.current += 1;
|
|
92
|
+
setToast({ seq: toastSeq.current, text });
|
|
93
|
+
};
|
|
94
|
+
const submit = (selection: Selection): void => {
|
|
95
|
+
void select(selection).then((result) => {
|
|
96
|
+
if (result === undefined || result.ok) {
|
|
97
|
+
setMemory(rememberEffort(storageOf(), selection.provider, selection.model, selection.reasoningEffort));
|
|
98
|
+
picker.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
raiseToast(result.error.code === 'session/writer-held'
|
|
102
|
+
? t('error.sessionInUse')
|
|
103
|
+
: t('error.action', { message: `${result.error.code}: ${result.error.message}` }));
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return <div ref={rootRef} className={css.pickerHost}>
|
|
108
|
+
<ModelPicker
|
|
109
|
+
open={open}
|
|
110
|
+
resolveText={resolveText}
|
|
111
|
+
state={state}
|
|
112
|
+
live={live}
|
|
113
|
+
facts={facts}
|
|
114
|
+
favorites={favorites}
|
|
115
|
+
busy={busy}
|
|
116
|
+
t={t}
|
|
117
|
+
rememberedOf={(provider, model) => effortFor(memory, provider, model)}
|
|
118
|
+
onPick={submit}
|
|
119
|
+
onToggleFavorite={(provider, model) => {
|
|
120
|
+
setFavorites(toggleFavorite(storageOf(), provider, model));
|
|
121
|
+
}}
|
|
122
|
+
onReload={load}
|
|
123
|
+
onClose={() => {
|
|
124
|
+
picker.close();
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
{toast !== null && <Toast
|
|
128
|
+
key={toast.seq}
|
|
129
|
+
text={toast.text}
|
|
130
|
+
icon={<IconWarningOutlineRegular />}
|
|
131
|
+
anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
|
|
132
|
+
onDone={() => {
|
|
133
|
+
setToast(null);
|
|
134
|
+
}}
|
|
135
|
+
/>}
|
|
136
|
+
</div>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
|
|
140
|
+
function storageOf(): FavoritesStorage | undefined {
|
|
141
|
+
try {
|
|
142
|
+
return globalThis.localStorage;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|