@lynn123411/dsh-model-submenu 0.3.5 → 0.3.11

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.
@@ -1,429 +0,0 @@
1
- import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import {
4
- IconChevronDownOutlineRegular,
5
- IconFlatListOutlineRegular,
6
- IconWarningOutlineRegular,
7
- MenuSurface,
8
- StateDot,
9
- Tag,
10
- Toast,
11
- useAnchoredPosition,
12
- } from '@deepseek-ai/dsh-client-ui-primitives'
13
- import type { ModelDirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
14
- import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
15
- import { ModelPicker } from './ModelPicker.tsx'
16
- import { ModelRow } from './ModelRow.tsx'
17
- import type { FactsSource } from './facts-source.ts'
18
- import {
19
- isFavorite,
20
- orderWithFavorites,
21
- readFavorites,
22
- toggleFavorite,
23
- type FavoriteModel,
24
- type FavoritesStorage,
25
- } from './favorites.ts'
26
- import { effortFor, readEfforts, rememberEffort, type RememberedEffort } from './effort-memory.ts'
27
- import type { EffortChoice, Selection } from './selection.ts'
28
- import { reasoningOf } from './rows.ts'
29
- import { effortChoices, planEffortCommit, planModelCommit } from './selection.ts'
30
- import css from './ModelSubmenu.module.css'
31
-
32
- type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'> & {
33
- /** The live settings-derived metadata the picker's line reads. */
34
- readonly factsSource: FactsSource
35
- }
36
-
37
- type ProviderGroup = ModelDirectoryState['groups'][number]
38
- type CatalogModel = ProviderGroup['models'][number]
39
-
40
- /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
41
- const MEASURE_STYLE = {
42
- visibility: 'hidden',
43
- left: 0,
44
- top: 0,
45
- } as const
46
-
47
- const cls = (...parts: readonly (string | false | undefined)[]): string => parts.filter((part) => part !== false && part !== undefined).join(' ')
48
-
49
- /**
50
- * The composer's model seat (`conversation.input.model`), taken over from the
51
- * built-in occupant with a provider-scoped menu: one provider's models above,
52
- * the reasoning row as segments, the cache warning, and the door to the picker
53
- * that lists every provider.
54
- *
55
- * Every click commits at once: a model, a reasoning level, or the picker door.
56
- * Closing the menu never writes anything, and the picker reads the level the
57
- * Host already accepted, so its "current" reading is never stale.
58
- */
59
- export function ModelSubmenu({ locked, available, directory, load, select, resolveText, t, factsSource }: ModelSubmenuProps) {
60
- const state = useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot())
61
- const [open, setOpen] = useState(false)
62
- const [pickerOpen, setPickerOpen] = useState(false)
63
- const [favorites, setFavorites] = useState<readonly FavoriteModel[]>(() => readFavorites(storageOf()))
64
- const [memory, setMemory] = useState<readonly RememberedEffort[]>(() => readEfforts(storageOf()))
65
- const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
66
- const toastSeq = useRef(0)
67
- const rootRef = useRef<HTMLDivElement | null>(null)
68
- const triggerRef = useRef<HTMLButtonElement | null>(null)
69
- const menuRef = useRef<HTMLDivElement | null>(null)
70
- const id = useId()
71
-
72
- const groups = state.groups
73
- const current = state.current
74
- const currentGroup = current === null ? undefined : groups.find((group) => group.id === current.provider)
75
- const currentModel = currentGroup?.models.find((model) => model.id === current?.model)
76
- const reasoning = currentModel?.reasoning
77
- const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
78
- /**
79
- * The trigger's caption. A model that advertises no levels still says so in
80
- * the same place: the blue caption beside the name reads 默认 rather than
81
- * disappearing, so the shape of the control never changes with the model.
82
- */
83
- const effortLabel = reasoning === undefined || effectiveEffort === undefined
84
- ? t('effort.providerDefault')
85
- : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort
86
- const choices = useMemo(() => effortChoices(reasoning, t('effort.providerDefault')), [reasoning, t])
87
- const busy = state.status === 'selecting'
88
- const live = useMemo(() => selectionOf(state), [state])
89
- const reason = useMemo(() => reasoningOf(groups), [groups])
90
- /** Resolve one catalog badge to its localized text and tone, matching the host seat. */
91
- const badgeOf = (model: CatalogModel) => model.badge === undefined
92
- ? undefined
93
- : <Tag tone={model.badge.tone} className={css.rowBadge}>{resolveText(model.badge.text)}</Tag>
94
- const triggerBadge = currentModel?.badge === undefined
95
- ? null
96
- : <Tag tone={currentModel.badge.tone} className={css.triggerBadge}>{resolveText(currentModel.badge.text)}</Tag>
97
- const facts = useSyncExternalStore((fn) => factsSource.subscribe(fn), () => factsSource.getSnapshot())
98
-
99
- const menuRows = useMemo(() => currentGroup === undefined ? [] : orderWithFavorites(
100
- currentGroup.models,
101
- (model) => isFavorite(favorites, currentGroup.id, model.id),
102
- ), [currentGroup, favorites])
103
-
104
- const raiseToast = (text: string) => {
105
- toastSeq.current += 1
106
- setToast({ seq: toastSeq.current, text })
107
- }
108
- const reload = () => {
109
- load()
110
- }
111
- const submit = (selection: Selection, onDone: (() => void) | null, onRejected?: () => void) => {
112
- select(selection).then((result) => {
113
- if (result === undefined || result.ok) {
114
- // The Host accepted it, so this is now the level this model uses.
115
- remember(selection.provider, selection.model, selection.reasoningEffort)
116
- onDone?.()
117
- return
118
- }
119
- onRejected?.()
120
- raiseToast(result.error.code === 'session/writer-held'
121
- ? t('error.sessionInUse')
122
- : t('error.action', { message: `${result.error.code}: ${result.error.message}` }))
123
- })
124
- }
125
- /** Close the menu. Closing writes nothing: every choice already committed on click. */
126
- const closeMenu = useCallback((restoreFocus: boolean) => {
127
- setOpen(false)
128
- if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
129
- }, [])
130
-
131
- useEffect(() => {
132
- if (!open) return
133
- const closeOutside = (event: MouseEvent) => {
134
- if (rootRef.current?.contains(event.target as Node) === true) return
135
- if (menuRef.current?.contains(event.target as Node) === true) return
136
- closeMenu(false)
137
- }
138
- document.addEventListener('mousedown', closeOutside)
139
- return () => {
140
- document.removeEventListener('mousedown', closeOutside)
141
- }
142
- }, [open, closeMenu])
143
-
144
- useEffect(() => {
145
- if (!open) return
146
- const frame = requestAnimationFrame(() => {
147
- const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
148
- const first = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"]:not([disabled])')
149
- const target = checked ?? first ?? triggerRef.current
150
- target?.scrollIntoView({ block: 'nearest' })
151
- target?.focus()
152
- })
153
- return () => { cancelAnimationFrame(frame) }
154
- }, [open])
155
-
156
- const menuPos = useAnchoredPosition({
157
- open: open && available,
158
- anchorRef: triggerRef,
159
- panelRef: menuRef,
160
- side: 'top',
161
- align: 'end',
162
- gap: 8,
163
- margin: 12,
164
- layoutKey: `${state.status}:${String(menuRows.length)}:${String(choices.length)}`,
165
- })
166
-
167
- if (!available) return null
168
-
169
- const show = () => {
170
- setOpen(true)
171
- reload()
172
- }
173
- /** The picker door: the level already landed on click, so only the surface changes. */
174
- const openPicker = () => {
175
- setOpen(false)
176
- setPickerOpen(true)
177
- }
178
- /** Record one model's level, so switching away and back restores it. */
179
- const remember = (provider: string, model: string, effort: string | undefined) => {
180
- setMemory(rememberEffort(storageOf(), provider, model, effort))
181
- }
182
- const chooseModel = (provider: string, model: CatalogModel) => {
183
- const picked = planModelCommit(live, provider, model.id, reason, effortFor(memory, provider, model.id))
184
- if (picked === null) {
185
- setOpen(false)
186
- queueMicrotask(() => { triggerRef.current?.focus() })
187
- return
188
- }
189
- // A model click is the decision: close at once, and keep the menu open when
190
- // the host rejects it so the user can try another model.
191
- setOpen(false)
192
- submit(picked, () => { queueMicrotask(() => { triggerRef.current?.focus() }) }, () => {
193
- // The host refused it: put the menu back so the user can pick another.
194
- setOpen(true)
195
- })
196
- }
197
- const rowButtons = () => [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]:not([disabled])') ?? [])]
198
- const moveFocus = (offset: number) => {
199
- const items = rowButtons()
200
- if (items.length === 0) return
201
- const active = items.findIndex((item) => item === document.activeElement)
202
- items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus()
203
- }
204
- const onEffortKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
205
- if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
206
- if (choices.length === 0 || live === null) return
207
- event.preventDefault()
208
- const index = choices.findIndex((choice) => choice.effort === effectiveEffort)
209
- const step = event.key === 'ArrowRight' ? 1 : -1
210
- const next = choices[((index === -1 ? 0 : index) + step + choices.length) % choices.length]
211
- if (next === undefined) return
212
- const pending = planEffortCommit(live, next.effort)
213
- if (pending !== null) submit(pending, null)
214
- }
215
- const onRootKeyDown = (event: React.KeyboardEvent) => {
216
- if (event.key === 'Escape' && open) {
217
- event.preventDefault()
218
- closeMenu(true)
219
- return
220
- }
221
- if (!open) return
222
- if (event.key === 'Tab') {
223
- if (event.shiftKey) {
224
- event.preventDefault()
225
- closeMenu(true)
226
- return
227
- }
228
- const focused = document.activeElement
229
- const rows = rowButtons()
230
- if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
231
- event.preventDefault()
232
- focused.click()
233
- return
234
- }
235
- if (focused !== triggerRef.current) return
236
- event.preventDefault()
237
- const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
238
- ;(checked ?? rows[0])?.focus()
239
- return
240
- }
241
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
242
- event.preventDefault()
243
- moveFocus(event.key === 'ArrowDown' ? 1 : -1)
244
- }
245
- }
246
- /**
247
- * The keyboard leaving the seat closes the menu — but that is not one of the
248
- * four closing gestures that write a mark, so it closes without committing.
249
- * Otherwise clicking the notice text or a divider inside the menu, which moves
250
- * focus without unmounting it, would land a level nobody asked to commit.
251
- */
252
- const onBlur = (event: React.FocusEvent) => {
253
- if (pickerOpen) return
254
- if (event.relatedTarget instanceof Node
255
- && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return
256
- setOpen(false)
257
- }
258
- const toggleFavoriteOf = (provider: string, model: string) => {
259
- setFavorites(toggleFavorite(storageOf(), provider, model))
260
- }
261
-
262
- const waiting = current === null && state.status === 'loading'
263
- const modelLabel = waiting ? t('trigger.loading') : currentModel?.name ?? (current === null ? t('trigger.fallback') : `${current.provider}/${current.model}`)
264
- const triggerAria = waiting
265
- ? t('trigger.loading')
266
- : current === null
267
- ? t('trigger.selectAria')
268
- : effortLabel === undefined
269
- ? t('trigger.aria', { model: modelLabel })
270
- : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
271
- const providerName = currentGroup?.name
272
- ?? state.failures.find((failure) => failure.id === current?.provider)?.name
273
- ?? current?.provider
274
- ?? ''
275
- const providerFailure = current === null ? undefined : state.failures.find((failure) => failure.id === current.provider)
276
-
277
- return (
278
- <div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
279
- <button
280
- ref={triggerRef}
281
- type="button"
282
- className={css.trigger}
283
- aria-label={triggerAria}
284
- aria-haspopup="menu"
285
- aria-expanded={open}
286
- aria-controls={open ? `${id}-menu` : undefined}
287
- title={effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`}
288
- disabled={locked}
289
- onClick={() => {
290
- if (open) closeMenu(false)
291
- else show()
292
- }}
293
- >
294
- <IconFlatListOutlineRegular className={css.triggerIcon} size={16} />
295
- {triggerBadge}
296
- <span className={css.triggerLabel}>{modelLabel}</span>
297
- {effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
298
- {busy
299
- ? <StateDot state="ongoing" />
300
- : <IconChevronDownOutlineRegular className={cls(css.chevron, open && css.chevronOpen)} />}
301
- </button>
302
- {open && createPortal((
303
- <MenuSurface
304
- ref={menuRef}
305
- id={`${id}-menu`}
306
- className={css.menu}
307
- style={menuPos ?? MEASURE_STYLE}
308
- role="group"
309
- aria-label={t('menu.aria')}
310
- aria-busy={state.status === 'loading' || busy}
311
- >
312
- <div className={css.providerName} title={providerName}>{providerName}</div>
313
- {state.status === 'loading' && <div className={css.notice}>{t('status.loading')}</div>}
314
- {state.error !== null && state.status === 'error' && (
315
- <div className={css.notice}>
316
- <span>{t('status.failed', { message: state.error })}</span>
317
- <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
318
- </div>
319
- )}
320
- {providerFailure !== undefined && (
321
- <div className={css.notice}>
322
- <span>{t('status.providerFailed', { name: providerFailure.name, message: providerFailure.message })}</span>
323
- <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
324
- </div>
325
- )}
326
- <div className={cls(css.groups, 'scrollable')} role="menu" aria-label={t('menu.aria')}>
327
- {menuRows.map((model) => (
328
- <ModelRow
329
- key={model.id}
330
- name={model.name}
331
- badge={badgeOf(model)}
332
- selected={current !== null && currentGroup !== undefined && current.provider === currentGroup.id && current.model === model.id}
333
- busy={busy}
334
- star="static"
335
- favorited={currentGroup !== undefined && isFavorite(favorites, currentGroup.id, model.id)}
336
- onPick={() => {
337
- if (currentGroup !== undefined) chooseModel(currentGroup.id, model)
338
- }}
339
- />
340
- ))}
341
- {menuRows.length === 0 && state.status === 'ready' && (
342
- <div className={css.notice}>{t('status.empty')}</div>
343
- )}
344
- </div>
345
- {choices.length > 0 && (
346
- <>
347
- <div className={css.divider} />
348
- <div className={css.effortRow}>
349
- <span className={css.effortHeading}>{t('menu.effort')}</span>
350
- <div className={css.effortTrack} role="radiogroup" aria-label={t('menu.effort')} onKeyDown={onEffortKeyDown}>
351
- {choices.map((choice: EffortChoice) => (
352
- <button
353
- key={choice.key}
354
- type="button"
355
- role="radio"
356
- aria-checked={choice.effort === effectiveEffort}
357
- className={css.effortSegment}
358
- disabled={busy}
359
- onClick={() => {
360
- if (live !== null) {
361
- const pending = planEffortCommit(live, choice.effort)
362
- if (pending !== null) submit(pending, null)
363
- }
364
- }}
365
- >
366
- {choice.label}
367
- </button>
368
- ))}
369
- </div>
370
- </div>
371
- <div className={css.divider} />
372
- </>
373
- )}
374
- <p className={css.hint}>{t('menu.hint')}</p>
375
- <div className={css.divider} />
376
- <button type="button" role="menuitem" className={css.moreRow} onClick={openPicker}>
377
- <IconFlatListOutlineRegular className={css.moreIcon} size={16} />
378
- <span>{t('menu.more')}</span>
379
- </button>
380
- </MenuSurface>
381
- ), document.body)}
382
- <ModelPicker
383
- open={pickerOpen}
384
- resolveText={resolveText}
385
- state={state}
386
- live={live}
387
- facts={facts}
388
- favorites={favorites}
389
- busy={busy}
390
- t={t}
391
- onPick={(selection) => { submit(selection, () => { setPickerOpen(false) }) }}
392
- onToggleFavorite={toggleFavoriteOf}
393
- onReload={reload}
394
- onClose={() => {
395
- setPickerOpen(false)
396
- queueMicrotask(() => { triggerRef.current?.focus() })
397
- }}
398
- />
399
- {toast !== null && (
400
- <Toast
401
- key={toast.seq}
402
- text={toast.text}
403
- icon={<IconWarningOutlineRegular />}
404
- anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
405
- onDone={() => {
406
- setToast(null)
407
- }}
408
- />
409
- )}
410
- </div>
411
- )
412
- }
413
-
414
- /** The selection in force, or null while the host has not resolved one. */
415
- function selectionOf(state: ModelDirectoryState): Selection | null {
416
- if (state.current === null) return null
417
- return state.current.reasoningEffort === undefined
418
- ? { provider: state.current.provider, model: state.current.model }
419
- : { provider: state.current.provider, model: state.current.model, reasoningEffort: state.current.reasoningEffort }
420
- }
421
-
422
- /** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
423
- function storageOf(): FavoritesStorage | undefined {
424
- try {
425
- return globalThis.localStorage
426
- } catch {
427
- return undefined
428
- }
429
- }