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