788 lines
32 KiB
TypeScript
788 lines
32 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
|
import {
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
dropdownMenuRow,
|
|
DropdownMenuSearch,
|
|
dropdownMenuSectionLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubTrigger
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { HighlightMatches } from '@/components/ui/highlight-matches'
|
|
import { usePointerQuiet } from '@/components/ui/keyboard-first'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import type { HermesGateway } from '@/hermes'
|
|
import { getLocalModelsStatus } from '@/hermes'
|
|
import { useI18n } from '@/i18n'
|
|
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
|
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
|
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
|
import { normalize } from '@/lib/text'
|
|
import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details'
|
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
|
import { cn } from '@/lib/utils'
|
|
import { $localModelsEnabled } from '@/store/local-models-flag'
|
|
import { $localRuntimeJobs, runningModelDownloads, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
|
|
import {
|
|
$visibleModels,
|
|
collapseModelFamilies,
|
|
DEFAULT_VISIBLE_PER_PROVIDER,
|
|
effectiveVisibleKeys,
|
|
type ModelFamily,
|
|
modelVisibilityKey,
|
|
setModelVisibilityOpen
|
|
} from '@/store/model-visibility'
|
|
import { openPreview } from '@/store/preview'
|
|
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
|
import { $defaultReasoningEffort } from '@/store/session'
|
|
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
|
|
|
import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
|
|
|
/** Whether a catalog row represents the session's current provider. Custom
|
|
* providers report the canonical `custom:<key>` identity from `model.options`
|
|
* while the row's slug is the bare config key, so exact slug equality never
|
|
* matches — check the row's alias set too (#87035). */
|
|
function isCurrentProvider(provider: ModelOptionProvider, currentProvider: string): boolean {
|
|
return provider.slug === currentProvider || (provider.aliases?.includes(currentProvider) ?? false)
|
|
}
|
|
|
|
// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel
|
|
// a way to dismiss itself so clicking a model row commits + closes, while the
|
|
// hover-revealed edit submenu (reasoning/fast) stays open to play with (its
|
|
// items preventDefault on select).
|
|
export const ModelMenuCloseContext = createContext<() => void>(() => {})
|
|
|
|
/** One model choice, everything a caller needs to act on a selection.
|
|
* `effort` is '' for "inherit the default" and 'none' for thinking off. */
|
|
export interface ModelChoice {
|
|
effort: string
|
|
fast: boolean
|
|
model: string
|
|
provider: string
|
|
}
|
|
|
|
/**
|
|
* What a surface DOES with the catalog. The menu renders and navigates; the
|
|
* controller owns meaning — the composer writes through to a live session,
|
|
* the kanban override just holds a value in dialog state.
|
|
*
|
|
* `presetFor` supplies the remembered settings shown on a non-active row.
|
|
* Returning `{}` is fine — the row then shows Hermes' defaults.
|
|
*/
|
|
export interface ModelMenuController {
|
|
/** Restore a model's remembered settings after it is selected. Separate from
|
|
* `setOptions` because it is one atomic "apply this model's preset" write,
|
|
* not a user editing one control — surfaces that write through to a session
|
|
* need to batch it. Values are already capability-gated by the menu. */
|
|
applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void
|
|
current: ModelChoice
|
|
presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean }
|
|
/** Commit a model row. Return false to abort (a failed session switch). */
|
|
select: (model: string, provider: string) => Promise<boolean | void> | void
|
|
/** Edit ONE option on a row. `isActive` says whether it's the current model. */
|
|
setOptions: (
|
|
patch: { effort?: string; fast?: boolean },
|
|
row: { isActive: boolean; model: string; provider: string }
|
|
) => void
|
|
}
|
|
|
|
interface ModelCatalogMenuProps {
|
|
controller: ModelMenuController
|
|
/** Rows appended under the catalog (Refresh Models, Edit Models, …). */
|
|
footer?: ReactNode
|
|
gateway?: HermesGateway
|
|
/** Owner-routed RPC for catalog reads. Preferred over `gateway.request` so
|
|
* a tile's menu queries the session owner's backend, not chrome's. */
|
|
request?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
/** Render the virtual `moa` provider's presets as a selectable section.
|
|
* Off for override surfaces, where a MoA preset isn't a worker model. */
|
|
includeMoa?: boolean
|
|
/** Registry source owning this catalog. Profile/session names are not unique
|
|
* across sources, so this participates in the React Query cache key. */
|
|
ownerConnectionId?: string
|
|
profile?: string
|
|
/** Session whose catalog to fetch. A live session's catalog can differ from
|
|
* the profile-global one, and the app invalidates the SESSION-scoped query
|
|
* key on model changes — a surface bound to a session must pass it or its
|
|
* menu goes stale. Detached surfaces (per-task overrides) omit it. */
|
|
sessionId?: null | string
|
|
}
|
|
|
|
interface ProviderGroup {
|
|
families: ModelFamily[]
|
|
provider: ModelOptionProvider
|
|
}
|
|
|
|
/**
|
|
* THE model catalog menu: searchable, provider-grouped, `-fast` families
|
|
* collapsed to one row, per-row hover submenu for thinking/effort/fast, full
|
|
* keyboard selection. Shared verbatim by the composer's model pill and by
|
|
* plugin surfaces that pick a model without a session behind it — so the two
|
|
* can never drift apart.
|
|
*/
|
|
export function ModelCatalogMenu({
|
|
controller,
|
|
footer,
|
|
gateway,
|
|
includeMoa = false,
|
|
ownerConnectionId,
|
|
profile = 'default',
|
|
request,
|
|
sessionId = null
|
|
}: ModelCatalogMenuProps) {
|
|
const { t } = useI18n()
|
|
const copy = t.shell.modelMenu
|
|
const copyPicker = t.modelPicker
|
|
const closeMenu = useContext(ModelMenuCloseContext)
|
|
const [search, setSearch] = useState('')
|
|
const collapsedProviders = useStoreCollapsed()
|
|
const defaultEffort = useDefaultEffort()
|
|
// Which models the user curated in Edit Models. Read HERE rather than taken
|
|
// as a prop: it's one global preference, so every surface that shows a
|
|
// catalog must show the same shortlist. A per-caller opt-in is how the board
|
|
// and the composer would end up disagreeing about what "my models" means.
|
|
const visibleModels = useStore($visibleModels)
|
|
|
|
const modelOptions = useQuery({
|
|
queryKey: modelOptionsQueryKey(profile, sessionId, ownerConnectionId),
|
|
// Gateway-first even with no session: a connected (possibly remote)
|
|
// gateway owns the model catalog, including virtual providers the local
|
|
// REST fallback can't know about (#53817).
|
|
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, profile, request, sessionId })
|
|
})
|
|
|
|
const loading = modelOptions.isPending && !modelOptions.data
|
|
|
|
// Every local-models read in this menu sits behind the --local launch
|
|
// flag: no status polling, no download rows, and the llamacpp provider
|
|
// group hides even when models are staged (the flag is strict).
|
|
const localModelsEnabled = $localModelsEnabled.get()
|
|
|
|
// Live load state for the managed local server: which model is loading
|
|
// into memory right now, with a REAL percent (per-tensor callback relayed
|
|
// over the router's SSE stream). Polled only while this menu is mounted
|
|
// (it unmounts on close); errors read as "nothing loading" — remote-only
|
|
// installs have no local-models routes.
|
|
const localStatus = useQuery({
|
|
queryKey: ['local-models-loading', profile],
|
|
queryFn: () => getLocalModelsStatus(),
|
|
enabled: localModelsEnabled,
|
|
refetchInterval: 2_000,
|
|
retry: false
|
|
})
|
|
|
|
const loadingModels: Record<string, LocalModelLoadProgress> = localStatus.data?.loading ?? {}
|
|
|
|
// Models on their way into the local library (downloads + quickstart runs
|
|
// still fetching bytes) — rendered as disabled progress rows so the user
|
|
// sees the model coming instead of wondering where it went. The jobs store
|
|
// republishes every ~700ms with fresh byte counts while anything runs; a
|
|
// whole-store subscription here would re-render the entire menu per tick
|
|
// (breaking open submenus and focus — the #72163 class). Subscribe to a
|
|
// STABLE identity projection instead: it changes only when a download
|
|
// starts or ends. Each row selects its own percent scalar.
|
|
const downloadsKey = useStoreSelector($localRuntimeJobs, jobs =>
|
|
localModelsEnabled
|
|
? runningModelDownloads(jobs)
|
|
.map(job => `${job.job_id}\u0000${job.target}`)
|
|
.join('\u0001')
|
|
: ''
|
|
)
|
|
|
|
const downloads = useMemo(
|
|
() =>
|
|
downloadsKey === ''
|
|
? []
|
|
: downloadsKey.split('\u0001').map(pair => {
|
|
const [jobId, target] = pair.split('\u0000')
|
|
|
|
return { jobId, target }
|
|
}),
|
|
[downloadsKey]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (localModelsEnabled) {
|
|
watchLocalRuntimeJobs()
|
|
}
|
|
}, [localModelsEnabled])
|
|
|
|
// A finished download turns into a real selectable model: refetch the
|
|
// catalog so the placeholder row is replaced while the menu is open.
|
|
const refetchOptions = modelOptions.refetch
|
|
|
|
useEffect(() => {
|
|
let prevActive = runningModelDownloads($localRuntimeJobs.get()).length > 0
|
|
|
|
return $localRuntimeJobs.listen(next => {
|
|
const active = runningModelDownloads(next).length > 0
|
|
|
|
if (prevActive && !active) {
|
|
void refetchOptions()
|
|
}
|
|
|
|
prevActive = active
|
|
})
|
|
}, [refetchOptions])
|
|
|
|
const error = modelOptions.error
|
|
? modelOptions.error instanceof Error
|
|
? modelOptions.error.message
|
|
: String(modelOptions.error)
|
|
: null
|
|
|
|
const providers = modelOptions.data?.providers
|
|
|
|
// The catalog carries MoA presets as a virtual `moa` provider row. Keep it
|
|
// out of the main groups so presets never show up twice.
|
|
const moaPresets = useMemo(
|
|
() => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []),
|
|
[providers, includeMoa]
|
|
)
|
|
|
|
const pickerProviders = useMemo(
|
|
() =>
|
|
providers?.filter(
|
|
provider =>
|
|
provider.slug.toLowerCase() !== 'moa' &&
|
|
// Strict --local gate: staged local models exist on disk, but
|
|
// without the flag the GUI doesn't offer them.
|
|
(localModelsEnabled || provider.slug !== LOCAL_PROVIDER_SLUG)
|
|
) ?? [],
|
|
[providers, localModelsEnabled]
|
|
)
|
|
|
|
const current = controller.current
|
|
|
|
const q = normalize(search)
|
|
|
|
// In-flight downloads render inside the Local provider group when it
|
|
// exists, else as their own trailing 'Local' group (first download —
|
|
// nothing staged yet, so the catalog has no local provider row).
|
|
const shownDownloads = q ? downloads.filter(job => (job.target || '').toLowerCase().includes(q)) : downloads
|
|
const hasLocalGroup = pickerProviders.some(provider => provider.slug === LOCAL_PROVIDER_SLUG)
|
|
|
|
// Resolve visibility HERE, against the catalog we actually fetched: an empty
|
|
// provider list would otherwise resolve to an empty key set that reads as
|
|
// "user hid everything" and blanks the menu on first open.
|
|
const shownKeys = useMemo(
|
|
() => effectiveVisibleKeys(visibleModels, pickerProviders),
|
|
[visibleModels, pickerProviders]
|
|
)
|
|
|
|
const groups = useMemo(
|
|
() => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys),
|
|
[pickerProviders, search, current.model, current.provider, shownKeys]
|
|
)
|
|
|
|
// Presets are searchable rows like everything else — an unfiltered preset
|
|
// sitting under zero model matches would otherwise become the "first match"
|
|
// Enter commits.
|
|
const shownMoaPresets = useMemo(
|
|
() => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
|
|
[moaPresets, q]
|
|
)
|
|
|
|
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
|
|
const caps = provider.capabilities?.[family.id]
|
|
const preset = controller.presetFor(provider.slug, family.id)
|
|
|
|
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
|
|
// id, so honor the remembered preset by selecting that sibling. Param-fast
|
|
// is applied through setOptions below instead.
|
|
const variantFast = !(caps?.fast ?? false) && !!family.fastId
|
|
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
|
|
|
|
if ((await controller.select(targetId, provider.slug)) === false) {
|
|
return
|
|
}
|
|
|
|
controller.applyPreset(
|
|
{
|
|
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
|
|
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
|
|
},
|
|
{ model: family.id, provider: provider.slug }
|
|
)
|
|
}
|
|
|
|
const selectMoaPreset = async (preset: string) => {
|
|
if ((await controller.select(preset, 'moa')) === false) {
|
|
return
|
|
}
|
|
|
|
closeMenu()
|
|
}
|
|
|
|
// ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
|
|
// One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
|
|
// so the selection can never sit on a hidden row.
|
|
type KbRow =
|
|
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
|
|
| { key: string; kind: 'moa'; preset: string }
|
|
|
|
const kbRows = useMemo<KbRow[]>(
|
|
() => [
|
|
...groups.flatMap(group =>
|
|
collapsedProviders.includes(group.provider.slug) && !search
|
|
? []
|
|
: group.families.map((family): KbRow => ({
|
|
family,
|
|
key: `${group.provider.slug}:${family.id}`,
|
|
kind: 'family',
|
|
provider: group.provider
|
|
}))
|
|
),
|
|
...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
|
|
],
|
|
[groups, collapsedProviders, search, shownMoaPresets]
|
|
)
|
|
|
|
const [kbOverride, setKbOverride] = useState<null | number>(null)
|
|
// A parked cursor is not a cursor in use: until the mouse actually moves,
|
|
// hover can't take rows out from under the keyboard.
|
|
const pointerQuiet = usePointerQuiet()
|
|
|
|
const rowIsCurrent = (row: KbRow) =>
|
|
row.kind === 'moa'
|
|
? current.provider === 'moa' && row.preset === current.model
|
|
: isCurrentProvider(row.provider, current.provider) &&
|
|
(row.family.id === current.model || row.family.fastId === current.model)
|
|
|
|
const autoIndex = q
|
|
? kbRows.length > 0
|
|
? 0
|
|
: -1
|
|
: kbRows.findIndex(row => rowIsCurrent(row) || (row.kind === 'family' && row.family.fastId === current.model))
|
|
|
|
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
|
|
const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
|
|
|
|
const stepKb = (delta: -1 | 1) => {
|
|
if (kbRows.length === 0) {
|
|
return
|
|
}
|
|
|
|
const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
|
|
|
|
setKbOverride((from + delta + kbRows.length) % kbRows.length)
|
|
}
|
|
|
|
const commitKbRow = () => {
|
|
const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
|
|
|
|
if (!row) {
|
|
return
|
|
}
|
|
|
|
if (row.kind === 'moa') {
|
|
void selectMoaPreset(row.preset)
|
|
|
|
return
|
|
}
|
|
|
|
if (!rowIsCurrent(row) && row.family.fastId !== current.model) {
|
|
void selectFamily(row.family, row.provider)
|
|
}
|
|
|
|
closeMenu()
|
|
}
|
|
|
|
// Keep the selected row in view while arrowing through the scrollable list.
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
|
|
}, [kbActiveKey])
|
|
|
|
const kbRowProps = (key: string) => {
|
|
const active = kbActiveKey === key
|
|
|
|
return {
|
|
className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
|
|
...(active ? { 'data-kb-active': '' } : {})
|
|
}
|
|
}
|
|
|
|
// Rows are hover-selectable, so they go inert with the pointer.
|
|
const quietRows = pointerQuiet && 'pointer-events-none'
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenuSearch
|
|
aria-label={copy.search}
|
|
onKeyDown={event => {
|
|
// Claim arrows and Enter from Radix so DOM focus stays in the input
|
|
// and Enter commits the highlighted row without a DownArrow first.
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
stepKb(event.key === 'ArrowDown' ? 1 : -1)
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
commitKbRow()
|
|
}
|
|
}}
|
|
onValueChange={value => {
|
|
setSearch(value)
|
|
setKbOverride(null)
|
|
}}
|
|
placeholder={copy.search}
|
|
value={search}
|
|
/>
|
|
|
|
<DropdownMenuSeparator className="mx-0" />
|
|
|
|
{loading ? (
|
|
<DropdownMenuGroup className="py-1">
|
|
{Array.from({ length: 4 }, (_, index) => (
|
|
<DropdownMenuItem
|
|
className={dropdownMenuRow}
|
|
disabled
|
|
key={index}
|
|
onSelect={event => event.preventDefault()}
|
|
>
|
|
<Skeleton className="h-4 w-full" />
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
) : error ? (
|
|
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
|
{error}
|
|
</DropdownMenuItem>
|
|
) : groups.length === 0 && moaPresets.length === 0 && shownDownloads.length === 0 ? (
|
|
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
|
{copy.noModels}
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
|
|
{groups.map(group => {
|
|
const slug = group.provider.slug
|
|
|
|
// Collapsed when the user stored it (and not while searching, which
|
|
// spans every model regardless of collapse state).
|
|
const collapsed = collapsedProviders.includes(slug) && !search
|
|
|
|
return (
|
|
<DropdownMenuGroup className="py-0.5" key={slug}>
|
|
<DropdownMenuItem
|
|
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
|
|
onSelect={event => {
|
|
event.preventDefault()
|
|
toggleCollapsedProvider(slug)
|
|
}}
|
|
textValue=""
|
|
>
|
|
<span className="truncate">
|
|
<HighlightMatches query={search} text={group.provider.name} />
|
|
</span>
|
|
<DisclosureCaret
|
|
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
|
|
open={!collapsed}
|
|
size="0.625rem"
|
|
/>
|
|
</DropdownMenuItem>
|
|
{!collapsed &&
|
|
group.families.map(family => {
|
|
// The active id may be the base or its -fast sibling; either
|
|
// way this one family row represents both.
|
|
const activeId =
|
|
isCurrentProvider(group.provider, current.provider) &&
|
|
(current.model === family.id || current.model === family.fastId)
|
|
? current.model
|
|
: null
|
|
|
|
const isCurrent = activeId !== null
|
|
const name = modelDisplayParts(family.id).name
|
|
const caps = group.provider.capabilities?.[family.id]
|
|
|
|
// Managed local model loading into memory right now:
|
|
// real load percent, keyed by exact model id (remote
|
|
// providers never collide with GGUF stems).
|
|
const loadProgress =
|
|
loadingModels[family.id] ?? (family.fastId ? loadingModels[family.fastId] : undefined)
|
|
|
|
// Effective settings for this row: the live choice when it's
|
|
// the active model, otherwise its remembered preset. Row
|
|
// label AND submenu read from these so they never disagree.
|
|
const preset = controller.presetFor(group.provider.slug, family.id)
|
|
const effEffort = isCurrent ? current.effort : (preset.effort ?? '')
|
|
const effFast = isCurrent ? current.fast : (preset.fast ?? false)
|
|
|
|
const fastControl: FastControl = resolveFastControl(
|
|
activeId ?? family.id,
|
|
group.provider.models ?? [],
|
|
caps?.fast ?? false,
|
|
effFast
|
|
)
|
|
|
|
const meta = [
|
|
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
|
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
// Clicking the row commits the model and closes; the edit
|
|
// submenu (reasoning/fast) is reached by HOVER, so you can
|
|
// tweak those without the click dismissing everything.
|
|
const activate = () => {
|
|
if (!isCurrent) {
|
|
void selectFamily(family, group.provider)
|
|
}
|
|
|
|
closeMenu()
|
|
}
|
|
|
|
return (
|
|
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
|
<DropdownMenuSubTrigger
|
|
hideChevron
|
|
onClick={activate}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
activate()
|
|
}
|
|
}}
|
|
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
<HighlightMatches query={search} text={name} />
|
|
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
|
</span>
|
|
{loadProgress ? (
|
|
<span
|
|
className="ml-auto flex shrink-0 items-center gap-1.5"
|
|
title={copyPicker.loadingIntoMemory}
|
|
>
|
|
<span className="h-1 w-14 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
|
<span
|
|
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
|
style={{ width: `${Math.max(2, loadProgress.percent)}%` }}
|
|
/>
|
|
</span>
|
|
<span className="text-[0.62rem] tabular-nums text-(--ui-text-tertiary)">
|
|
{loadProgress.percent}%
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
{isCurrent ? (
|
|
<Codicon
|
|
className={cn('text-foreground', loadProgress ? 'ml-1' : 'ml-auto')}
|
|
name="check"
|
|
size="0.75rem"
|
|
/>
|
|
) : null}
|
|
</DropdownMenuSubTrigger>
|
|
<ModelEditSubmenu
|
|
canDisableReasoning={caps?.can_disable_reasoning}
|
|
defaultEffort={defaultEffort}
|
|
effort={effEffort}
|
|
fastControl={fastControl}
|
|
isActive={isCurrent}
|
|
model={family.id}
|
|
onOpenDetails={turkservisComboDetailsUrl(group.provider.api_url, family.id) ? () => {
|
|
const url = turkservisComboDetailsUrl(group.provider.api_url, family.id)!
|
|
|
|
closeMenu()
|
|
openPreview({ kind: 'url', label: t.shell.modelOptions.comboDetails, source: url, url })
|
|
} : undefined}
|
|
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
|
|
onSetOptions={patch =>
|
|
controller.setOptions(patch, {
|
|
isActive: isCurrent,
|
|
model: family.id,
|
|
provider: group.provider.slug
|
|
})
|
|
}
|
|
provider={group.provider.slug}
|
|
reasoning={caps?.reasoning ?? true}
|
|
/>
|
|
</DropdownMenuSub>
|
|
)
|
|
})}
|
|
{!collapsed &&
|
|
slug === LOCAL_PROVIDER_SLUG &&
|
|
shownDownloads.map(job => (
|
|
<DownloadingModelRow jobId={job.jobId} key={job.jobId} target={job.target} />
|
|
))}
|
|
</DropdownMenuGroup>
|
|
)
|
|
})}
|
|
{!hasLocalGroup && shownDownloads.length > 0 && (
|
|
<DropdownMenuGroup className="py-0.5" key="local-downloads">
|
|
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
|
{copyPicker.localDownloadsHeading}
|
|
</DropdownMenuLabel>
|
|
{shownDownloads.map(job => (
|
|
<DownloadingModelRow jobId={job.jobId} key={job.jobId} target={job.target} />
|
|
))}
|
|
</DropdownMenuGroup>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{shownMoaPresets.length > 0 ? (
|
|
<div className={cn(quietRows)}>
|
|
<DropdownMenuSeparator className="mx-0" />
|
|
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{t.surfaces.moaPresets}</DropdownMenuLabel>
|
|
{shownMoaPresets.map(preset => {
|
|
const isCurrentMoa = current.provider === 'moa' && current.model === preset
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
key={`moa:${preset}`}
|
|
onSelect={event => {
|
|
event.preventDefault()
|
|
void selectMoaPreset(preset)
|
|
}}
|
|
{...kbRowProps(`moa:${preset}`)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
MoA: <HighlightMatches query={search} text={preset} />
|
|
</span>
|
|
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Curation belongs to the catalog, not to one host: wherever you can
|
|
pick a model you can say which models you want, and the shortlist is
|
|
the same everywhere because it's one stored preference. It shares the
|
|
host footer's group rather than opening a second one, so a host that
|
|
contributes rows (the composer's Refresh Models) keeps the single
|
|
trailing block it has always rendered. */}
|
|
<DropdownMenuSeparator className="mx-0" />
|
|
{footer}
|
|
<DropdownMenuItem
|
|
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
|
onSelect={() => setModelVisibilityOpen(true)}
|
|
>
|
|
<Codicon name="settings-gear" size="0.75rem" />
|
|
{copy.editModels}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** Re-exported so callers building a footer row match the catalog's rows. */
|
|
export { dropdownMenuRow }
|
|
|
|
// The backend's provider row for staged local models (inventory.py's
|
|
// _local_runtime_row). Downloads-in-flight attach to this group.
|
|
const LOCAL_PROVIDER_SLUG = 'llamacpp'
|
|
|
|
// A model still downloading: visible so the user knows it's coming (and
|
|
// where it will land), disabled so it can't be selected early, with the
|
|
// same byte progress the Local Models pane shows. Percent is selected HERE,
|
|
// per row, so the 700ms byte ticks repaint this leaf only — the menu tree
|
|
// above subscribes to download identity, not progress.
|
|
function DownloadingModelRow({ jobId, target }: { jobId: string; target: string }) {
|
|
const { t } = useI18n()
|
|
const copy = t.modelPicker
|
|
|
|
const percent = useStoreSelector($localRuntimeJobs, jobs => jobs.find(job => job.job_id === jobId)?.percent ?? null)
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
className={cn(dropdownMenuRow, 'opacity-60')}
|
|
disabled
|
|
onSelect={event => event.preventDefault()}
|
|
textValue=""
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{target}</span>
|
|
<span className="ml-auto flex shrink-0 items-center gap-1.5" title={copy.downloading}>
|
|
<span className="h-1 w-14 overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
|
<span
|
|
className="block h-full rounded-full bg-primary transition-[width] duration-500"
|
|
style={{ width: `${Math.max(2, percent ?? 0)}%` }}
|
|
/>
|
|
</span>
|
|
<span className="text-[0.62rem] tabular-nums text-(--ui-text-tertiary)">
|
|
{typeof percent === 'number' ? `${percent}%` : copy.downloading}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuItem>
|
|
)
|
|
}
|
|
|
|
// Collapsed we show the user's chosen models (or the curated default); typing
|
|
// spans every available model so anything is reachable past the cut. A search
|
|
// is itself a narrowing action, so we do NOT cap per-provider matches.
|
|
function groupModels(
|
|
providers: ModelOptionProvider[],
|
|
search: string,
|
|
current: { model: string; provider: string },
|
|
visible: Set<string> | null
|
|
): ProviderGroup[] {
|
|
const q = normalize(search)
|
|
const groups: ProviderGroup[] = []
|
|
|
|
for (const provider of providers) {
|
|
const allFamilies = collapseModelFamilies(provider.models ?? [])
|
|
|
|
if (allFamilies.length === 0) {
|
|
continue
|
|
}
|
|
|
|
const matches = (family: ModelFamily) =>
|
|
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
|
|
.toLowerCase()
|
|
.includes(q)
|
|
|
|
let shown: Set<string>
|
|
|
|
if (q) {
|
|
// Search spans every family, regardless of visibility.
|
|
shown = new Set(allFamilies.filter(matches).map(family => family.id))
|
|
} else if (visible) {
|
|
// User has customized which models show — honor their selection exactly.
|
|
shown = new Set(
|
|
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
|
|
)
|
|
} else {
|
|
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
|
|
}
|
|
|
|
// Always include the active model — but keep every row in the provider's
|
|
// stable curated order, so selecting a model can't shuffle the list. While
|
|
// SEARCHING the pin is skipped: a query means "show me matches".
|
|
const activeId =
|
|
!q && isCurrentProvider(provider, current.provider) && current.model
|
|
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
|
|
: undefined
|
|
|
|
const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
|
|
|
|
if (families.length > 0) {
|
|
groups.push({ families, provider })
|
|
}
|
|
}
|
|
|
|
// Stable, logical group order: alphabetical by provider name. (The backend
|
|
// floats the current provider first, which would reshuffle on every switch.)
|
|
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
|
|
|
|
return groups
|
|
}
|
|
|
|
// Small hooks kept at the bottom so the component reads top-down.
|
|
function useStoreCollapsed(): string[] {
|
|
return useStore($collapsedProviders)
|
|
}
|
|
|
|
function useDefaultEffort(): string {
|
|
return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
|
|
}
|