mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(ui): make the home model picker the shared model picker
Move HomeModelPicker to components/ModelPicker and give it what the other pickers had: multiple selection with chips, free text, a clear button, a search box on demand, a form-field look, and loading and empty states. Every entry shows 'alias of X', disabled, pinned or broken-alias tags from the capabilities listing, and entries that cannot be used are not selectable. Auto-select skips them. Add-ons keep HomeModelPicker as a name for the same component. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
15 files changed
+454
-254
No files matched your search
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "ergänzen"
|
||||
},
|
||||
"notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." }
|
||||
"notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." },
|
||||
"picker": {
|
||||
"aliasOf": "Alias von {{target}}",
|
||||
"aliasBroken": "defekter Alias",
|
||||
"disabled": "deaktiviert",
|
||||
"pinned": "angeheftet",
|
||||
"useTyped": "\"{{name}}\" verwenden",
|
||||
"empty": "Keine Modelle verfügbar",
|
||||
"add": "Modell hinzufügen",
|
||||
"remove": "{{name}} entfernen",
|
||||
"clear": "Leeren"
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,16 @@
|
||||
"cold": "not loaded",
|
||||
"loadedNow": "Loaded now",
|
||||
"installed": "Installed",
|
||||
"vision": "Understands images"
|
||||
"vision": "Understands images",
|
||||
"aliasOf": "alias of {{target}}",
|
||||
"aliasBroken": "broken alias",
|
||||
"disabled": "disabled",
|
||||
"pinned": "pinned",
|
||||
"useTyped": "Use \"{{name}}\"",
|
||||
"empty": "No models available",
|
||||
"add": "Add a model",
|
||||
"remove": "Remove {{name}}",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"slash": {
|
||||
"label": "Actions",
|
||||
|
||||
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "completar"
|
||||
},
|
||||
"notice": { "appUnavailable": "La aplicación ya no está disponible." }
|
||||
"notice": { "appUnavailable": "La aplicación ya no está disponible." },
|
||||
"picker": {
|
||||
"aliasOf": "alias de {{target}}",
|
||||
"aliasBroken": "alias roto",
|
||||
"disabled": "desactivado",
|
||||
"pinned": "fijado",
|
||||
"useTyped": "Usar \"{{name}}\"",
|
||||
"empty": "No hay modelos disponibles",
|
||||
"add": "Añadir un modelo",
|
||||
"remove": "Quitar {{name}}",
|
||||
"clear": "Borrar"
|
||||
}
|
||||
}
|
||||
@@ -142,5 +142,16 @@
|
||||
},
|
||||
"complete": "compléter"
|
||||
},
|
||||
"notice": { "appUnavailable": "L’application n’est plus disponible." }
|
||||
"notice": { "appUnavailable": "L’application n’est plus disponible." },
|
||||
"picker": {
|
||||
"aliasOf": "alias de {{target}}",
|
||||
"aliasBroken": "alias cassé",
|
||||
"disabled": "désactivé",
|
||||
"pinned": "épinglé",
|
||||
"useTyped": "Utiliser \"{{name}}\"",
|
||||
"empty": "Aucun modèle disponible",
|
||||
"add": "Ajouter un modèle",
|
||||
"remove": "Retirer {{name}}",
|
||||
"clear": "Effacer"
|
||||
}
|
||||
}
|
||||
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "lengkapi"
|
||||
},
|
||||
"notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." }
|
||||
"notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." },
|
||||
"picker": {
|
||||
"aliasOf": "alias dari {{target}}",
|
||||
"aliasBroken": "alias rusak",
|
||||
"disabled": "nonaktif",
|
||||
"pinned": "disematkan",
|
||||
"useTyped": "Gunakan \"{{name}}\"",
|
||||
"empty": "Tidak ada model",
|
||||
"add": "Tambah model",
|
||||
"remove": "Hapus {{name}}",
|
||||
"clear": "Kosongkan"
|
||||
}
|
||||
}
|
||||
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "completa"
|
||||
},
|
||||
"notice": { "appUnavailable": "L’app non è più disponibile." }
|
||||
"notice": { "appUnavailable": "L’app non è più disponibile." },
|
||||
"picker": {
|
||||
"aliasOf": "alias di {{target}}",
|
||||
"aliasBroken": "alias non valido",
|
||||
"disabled": "disattivato",
|
||||
"pinned": "fissato",
|
||||
"useTyped": "Usa \"{{name}}\"",
|
||||
"empty": "Nessun modello disponibile",
|
||||
"add": "Aggiungi un modello",
|
||||
"remove": "Rimuovi {{name}}",
|
||||
"clear": "Cancella"
|
||||
}
|
||||
}
|
||||
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "완성"
|
||||
},
|
||||
"notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." }
|
||||
"notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." },
|
||||
"picker": {
|
||||
"aliasOf": "{{target}}의 별칭",
|
||||
"aliasBroken": "깨진 별칭",
|
||||
"disabled": "비활성",
|
||||
"pinned": "고정됨",
|
||||
"useTyped": "\"{{name}}\" 사용",
|
||||
"empty": "사용 가능한 모델 없음",
|
||||
"add": "모델 추가",
|
||||
"remove": "{{name}} 제거",
|
||||
"clear": "지우기"
|
||||
}
|
||||
}
|
||||
@@ -154,5 +154,16 @@
|
||||
},
|
||||
"complete": "completar"
|
||||
},
|
||||
"notice": { "appUnavailable": "O aplicativo não está mais disponível." }
|
||||
"notice": { "appUnavailable": "O aplicativo não está mais disponível." },
|
||||
"picker": {
|
||||
"aliasOf": "alias de {{target}}",
|
||||
"aliasBroken": "alias quebrado",
|
||||
"disabled": "desativado",
|
||||
"pinned": "fixado",
|
||||
"useTyped": "Usar \"{{name}}\"",
|
||||
"empty": "Nenhum modelo disponível",
|
||||
"add": "Adicionar um modelo",
|
||||
"remove": "Remover {{name}}",
|
||||
"clear": "Limpar"
|
||||
}
|
||||
}
|
||||
@@ -139,5 +139,16 @@
|
||||
},
|
||||
"complete": "补全"
|
||||
},
|
||||
"notice": { "appUnavailable": "应用已不可用。" }
|
||||
"notice": { "appUnavailable": "应用已不可用。" },
|
||||
"picker": {
|
||||
"aliasOf": "{{target}} 的别名",
|
||||
"aliasBroken": "别名失效",
|
||||
"disabled": "已停用",
|
||||
"pinned": "已固定",
|
||||
"useTyped": "使用 \"{{name}}\"",
|
||||
"empty": "没有可用模型",
|
||||
"add": "添加模型",
|
||||
"remove": "移除 {{name}}",
|
||||
"clear": "清除"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -13,7 +13,7 @@ import {
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import Modal from '../components/Modal'
|
||||
import Lightbox from '../components/Lightbox'
|
||||
import HomeModelPicker from '../components/home/HomeModelPicker'
|
||||
import ModelPicker from '../components/ModelPicker'
|
||||
import { useMediaCapture, audioBufferToWavBlob } from '../hooks/useMediaCapture'
|
||||
import { useModels } from '../hooks/useModels'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
@@ -71,7 +71,7 @@ export const host = Object.freeze({
|
||||
Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
|
||||
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
|
||||
DistributionBars, EmbeddingInspector, TabSwitch,
|
||||
LoadingSpinner, Modal, Lightbox, HomeModelPicker,
|
||||
LoadingSpinner, Modal, Lightbox, ModelPicker, HomeModelPicker: ModelPicker,
|
||||
},
|
||||
hooks: {
|
||||
useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModels } from '../hooks/useModels'
|
||||
import { readLastModel, writeLastModel, readPreferredModel } from '../utils/lastModel'
|
||||
import Icon from './Icon'
|
||||
|
||||
// How many models before the list gets a filter box, when `searchable` is left
|
||||
// to decide.
|
||||
const FILTER_FROM = 8
|
||||
|
||||
// The one model picker: a trigger and a listbox. A model is "warm" when the
|
||||
// server reports it loaded, "cold" otherwise. Every entry the server lists
|
||||
// shows what it is: an alias says which model it stands for, a disabled model
|
||||
// cannot be chosen, a pinned one is marked, and an alias that points nowhere is
|
||||
// listed as broken and cannot be chosen.
|
||||
//
|
||||
// value, onChange a name; with `multiple`, an array of names
|
||||
// capability only models that have it (also read to remember a choice)
|
||||
// multiple pick several; the list stays open and the picks show as chips
|
||||
// allowCustom a typed name that is not in the list can be used as it is
|
||||
// clearable a button empties the value
|
||||
// searchable true: always a filter box; false: never; omitted: from 8 models
|
||||
// variant "chip" (default, the compact Home chip) or "field" (a form control)
|
||||
// autoSelect choose the remembered or the first model when the value is not listed
|
||||
// models, loading the list is already held by the caller, so no second fetch
|
||||
// grouped, loadedIds, showWarm, describe, onOpen, footer, labels
|
||||
// defaultChoice, defaultSelected, onChooseDefault a first row for "use the default"
|
||||
const ModelPicker = forwardRef(function ModelPicker(
|
||||
{
|
||||
value, onChange, capability, loadedIds, disabled = false, placeholder,
|
||||
models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer,
|
||||
showWarm = true, labels, autoSelect = true, rememberSelection = true,
|
||||
defaultChoice, defaultSelected = false, onChooseDefault, id, describedBy,
|
||||
multiple = false, allowCustom = false, clearable = false, searchable,
|
||||
variant = 'chip', className = '',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation('home')
|
||||
const own = useModels(capability, { enabled: !givenModels })
|
||||
const models = givenModels || own.models
|
||||
const loading = givenModels ? !!givenLoading : own.loading
|
||||
const byId = useMemo(() => new Map(models.map(m => [m.id, m])), [models])
|
||||
const names = useMemo(() => models.map(m => m.id), [models])
|
||||
const picked = useMemo(() => (multiple ? (Array.isArray(value) ? value : []) : []), [multiple, value])
|
||||
const instanceId = useId()
|
||||
const menuId = `${instanceId}-menu`
|
||||
const listId = `${instanceId}-list`
|
||||
// A null row is a non-model action, never an installed model ID.
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const rootRef = useRef(null)
|
||||
const chipRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
const filterRef = useRef(null)
|
||||
|
||||
// A chooseable model is listed, enabled and, when it is an alias, resolved.
|
||||
const usable = useCallback((name) => {
|
||||
const m = byId.get(name)
|
||||
return !!m && !m.disabled && !m.alias_error
|
||||
}, [byId])
|
||||
const usableNames = useMemo(() => names.filter(usable), [names, usable])
|
||||
|
||||
// The remembered model if it is still there, otherwise the first one.
|
||||
// Auto-select is not a user choice, so it is not written back.
|
||||
useEffect(() => {
|
||||
if (multiple || allowCustom || !autoSelect || defaultSelected || usableNames.length === 0) return
|
||||
if (value && usableNames.includes(value)) return
|
||||
const preferred = readPreferredModel(capability)
|
||||
const remembered = preferred && usableNames.includes(preferred) ? preferred : readLastModel(capability)
|
||||
onChange(remembered && usableNames.includes(remembered) ? remembered : usableNames[0])
|
||||
}, [usableNames, value, onChange, capability, autoSelect, defaultSelected, multiple, allowCustom])
|
||||
|
||||
const showFilter = searchable ?? names.length >= FILTER_FROM
|
||||
|
||||
// Loaded models first when the list is grouped; each group keeps its order.
|
||||
// Rows are { name } for a model and { custom } for the typed name.
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const matches = (n) => {
|
||||
if (!q) return true
|
||||
const m = byId.get(n)
|
||||
return n.toLowerCase().includes(q) || (m?.alias_of || '').toLowerCase().includes(q)
|
||||
}
|
||||
const found = names.filter(matches)
|
||||
const ordered = grouped ? [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] : found
|
||||
const rows = ordered.map(name => ({ name }))
|
||||
const typed = query.trim()
|
||||
if (allowCustom && typed && !names.includes(typed)) rows.push({ custom: typed })
|
||||
return defaultChoice && (!q || defaultChoice.toLowerCase().includes(q)) ? [{ name: null }, ...rows] : rows
|
||||
}, [names, byId, query, grouped, loadedIds, defaultChoice, allowCustom])
|
||||
|
||||
const close = useCallback((restoreFocus = false) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
if (restoreFocus) chipRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
if (disabled || (names.length === 0 && !defaultChoice && !allowCustom)) return
|
||||
setActive(defaultSelected || multiple ? 0 : Math.max(0, names.indexOf(value) + (defaultChoice ? 1 : 0)))
|
||||
setQuery('')
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [disabled, names, value, onOpen, defaultChoice, defaultSelected, multiple, allowCustom])
|
||||
|
||||
useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, close])
|
||||
|
||||
// Focus moves into the menu so the arrow keys work, and back to the chip when
|
||||
// it closes by keyboard.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const el = filterRef.current || menuRef.current
|
||||
el?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
menuRef.current?.querySelector('[data-active="true"]')?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [open, active, shown])
|
||||
|
||||
const pick = useCallback((row) => {
|
||||
if (!row) return
|
||||
if (row.name === null) { onChooseDefault?.(); close(true); return }
|
||||
const name = row.custom ?? row.name
|
||||
if (row.custom === undefined && !usable(name)) return
|
||||
if (rememberSelection && !multiple) writeLastModel(capability, name)
|
||||
if (multiple) {
|
||||
onChange(picked.includes(name) ? picked.filter(n => n !== name) : [...picked, name])
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
onChange(name)
|
||||
close(true)
|
||||
}, [capability, onChange, close, rememberSelection, onChooseDefault, usable, multiple, picked])
|
||||
|
||||
const onMenuKey = (e) => {
|
||||
const n = shown.length
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive(a => (a + 1) % n) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) }
|
||||
else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) }
|
||||
else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); if (active < n) pick(shown[active]) }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
|
||||
else if (e.key === 'Tab') close()
|
||||
}
|
||||
|
||||
const isWarm = (name) => loadedIds?.has(name)
|
||||
const current = multiple ? '' : (defaultSelected ? defaultChoice : value)
|
||||
const emptyLabel = multiple ? (placeholder || t('picker.add')) : (placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none'))))
|
||||
const label = current || emptyLabel
|
||||
const activeId = active < shown.length ? `${instanceId}-opt-${active}` : undefined
|
||||
const trigger = variant === 'field' ? 'home-chip home-chip--field' : 'home-chip'
|
||||
|
||||
const badges = (name) => {
|
||||
const m = byId.get(name)
|
||||
if (!m) return null
|
||||
return (
|
||||
<>
|
||||
{m.alias_of && !m.alias_error && <span className="home-menu__tag" data-kind="alias">{t('picker.aliasOf', { target: m.alias_of })}</span>}
|
||||
{m.alias_error && <span className="home-menu__tag" data-kind="broken" title={m.alias_error}>{t('picker.aliasBroken')}</span>}
|
||||
{m.disabled && <span className="home-menu__tag" data-kind="disabled">{t('picker.disabled')}</span>}
|
||||
{m.pinned && <span className="home-menu__tag" data-kind="pinned">{t('picker.pinned')}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`home-pick${variant === 'field' ? ' home-pick--field' : ''} ${className}`.trim()} ref={rootRef}>
|
||||
{multiple && picked.length > 0 && (
|
||||
<ul className="home-pick__chips" aria-label={labels?.label || t('picker.label')}>
|
||||
{picked.map(name => (
|
||||
<li key={name} className="home-pick__tag">
|
||||
<code>{name}</code>
|
||||
<button type="button" className="home-pick__remove" disabled={disabled}
|
||||
aria-label={t('picker.remove', { name })}
|
||||
onClick={() => onChange(picked.filter(n => n !== name))}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="home-pick__row">
|
||||
<button
|
||||
ref={chipRef}
|
||||
id={id}
|
||||
aria-label={labels?.label}
|
||||
aria-describedby={[labels?.label && `${instanceId}-selection`, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
type="button"
|
||||
className={`${trigger} home-chip--model`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
title={labels?.title || t('picker.title')}
|
||||
data-testid="home-model-chip"
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
|
||||
>
|
||||
<Icon name="cube" />
|
||||
{showWarm && !multiple && !defaultSelected && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
<span id={labels?.label ? `${instanceId}-selection` : undefined} className="home-chip__text">{label}</span>
|
||||
{!multiple && current && byId.get(current)?.alias_of && !byId.get(current)?.alias_error && (
|
||||
<small className="home-chip__alias">{t('picker.aliasOf', { target: byId.get(current).alias_of })}</small>
|
||||
)}
|
||||
<Icon name="chevron-down" className="home-chip__caret" />
|
||||
</button>
|
||||
{clearable && !multiple && value && (
|
||||
<button type="button" className="home-pick__remove" disabled={disabled}
|
||||
aria-label={t('picker.clear')} onClick={() => onChange('')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="home-menu home-menu--models" id={menuId} onKeyDown={onMenuKey}>
|
||||
{(showFilter || allowCustom) && (
|
||||
<input
|
||||
ref={filterRef}
|
||||
type="search"
|
||||
className="home-menu__filter"
|
||||
placeholder={t('picker.filter')}
|
||||
aria-label={t('picker.filter')}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId}
|
||||
/>
|
||||
)}
|
||||
<ul
|
||||
ref={menuRef}
|
||||
id={listId}
|
||||
className="home-menu__list"
|
||||
role="listbox"
|
||||
aria-multiselectable={multiple || undefined}
|
||||
aria-label={labels?.label || t('picker.label')}
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={activeId}
|
||||
>
|
||||
{!grouped && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
|
||||
{loading && <li className="home-menu__empty" role="presentation">{t('picker.loading')}</li>}
|
||||
{!loading && shown.length === 0 && (
|
||||
<li className="home-menu__empty" role="presentation">{names.length === 0 ? t('picker.empty') : t('picker.noMatch')}</li>
|
||||
)}
|
||||
{shown.map((row, i) => {
|
||||
const { name, custom } = row
|
||||
const warm = name != null && isWarm(name)
|
||||
const info = name != null && describe ? describe(name, warm) : null
|
||||
const chosen = custom !== undefined ? false
|
||||
: name === null ? defaultSelected
|
||||
: multiple ? picked.includes(name) : !defaultSelected && name === value
|
||||
const blocked = name != null && !usable(name)
|
||||
const prev = shown[i - 1]
|
||||
const first = grouped && name != null && (i === 0 || prev?.name == null || isWarm(prev.name) !== warm)
|
||||
return (
|
||||
<Fragment key={custom !== undefined ? `${instanceId}-custom` : name === null ? `${instanceId}-default` : name}>
|
||||
{first && (
|
||||
<li className="home-menu__head" role="presentation">
|
||||
{warm ? t('picker.loadedNow') : t('picker.installed')}
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
id={`${instanceId}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={chosen}
|
||||
aria-disabled={blocked || undefined}
|
||||
data-active={i === active}
|
||||
data-blocked={blocked || undefined}
|
||||
className={`home-menu__item${info ? ' home-menu__item--rich' : ''}`}
|
||||
onMouseMove={() => setActive(i)}
|
||||
onClick={() => pick(row)}
|
||||
>
|
||||
{showWarm && name != null && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
{custom !== undefined ? (
|
||||
<code>{t('picker.useTyped', { name: custom })}</code>
|
||||
) : info ? (
|
||||
<>
|
||||
<span className="home-menu__main">
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{badges(name)}
|
||||
{(info.size || info.vision) && (
|
||||
<small className="home-menu__caps">
|
||||
{info.size && <span>{info.size}</span>}
|
||||
{info.vision && <Icon name="eye" title={t('picker.vision')} />}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="home-menu__state" data-tone={info.fit?.tone}>
|
||||
<b>{chosen ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
|
||||
{info.fit && <small>{info.fit.text}</small>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{name != null && badges(name)}
|
||||
{chosen
|
||||
? <Icon name="check" className="home-menu__check" />
|
||||
: (showWarm && name != null && !blocked && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ModelPicker
|
||||
@@ -12,7 +12,7 @@ import { useModelFit } from '../../hooks/useModelFit'
|
||||
import { useCommandsNotice } from '../../hooks/useCommandsNotice'
|
||||
import { fillStyle, hostMemory, memoryFigure } from '../home/memory'
|
||||
import HomeComposer from '../home/HomeComposer'
|
||||
import HomeModelPicker from '../home/HomeModelPicker'
|
||||
import ModelPicker from '../ModelPicker'
|
||||
import AddonSlot from '../../addons/AddonSlot'
|
||||
import AgentPicker from './AgentPicker'
|
||||
import SkillsMenu from './SkillsMenu'
|
||||
@@ -146,7 +146,7 @@ export default function ChatComposer({
|
||||
const memory = hostMemory(modelFit.resources)
|
||||
const memoryNumbers = memory ? memoryFigure(memory.used, memory.total) : null
|
||||
const picker = (
|
||||
<HomeModelPicker
|
||||
<ModelPicker
|
||||
ref={pickerRef}
|
||||
value={effectiveModel}
|
||||
onChange={chatId && onPickModel ? onPickModel : setDraftModel}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModels } from '../../hooks/useModels'
|
||||
import { readLastModel, writeLastModel, readPreferredModel } from '../../utils/lastModel'
|
||||
import Icon from '../Icon'
|
||||
|
||||
// How many models before the list gets a filter box.
|
||||
const FILTER_FROM = 8
|
||||
|
||||
// The model chip on the Home command bar and its listbox. A model is "warm"
|
||||
// when the server reports it loaded (the same /system list the status strip
|
||||
// reads), "cold" otherwise. Nothing about load time is shown: the API does not
|
||||
// report it for an installed model.
|
||||
//
|
||||
// Chat uses the same chip and adds, all optional:
|
||||
// models, loading the page already holds the list, so no second fetch
|
||||
// grouped "Loaded now" and "Installed" instead of one list
|
||||
// describe(name) { vision, size, fit: { tone, text } } for a row
|
||||
// onOpen called when the list opens (to read memory and sizes)
|
||||
// footer a node under the list (the memory bar)
|
||||
// showWarm false for a list whose names are not loadable models (Talk's pipelines)
|
||||
// labels { title, heading, label, none } to replace the chat wording
|
||||
const HomeModelPicker = forwardRef(function HomeModelPicker(
|
||||
{
|
||||
value, onChange, capability, loadedIds, disabled = false, placeholder,
|
||||
models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer,
|
||||
showWarm = true, labels, autoSelect = true, rememberSelection = true,
|
||||
defaultChoice, defaultSelected = false, onChooseDefault, id, describedBy,
|
||||
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation('home')
|
||||
const own = useModels(capability, { enabled: !givenModels })
|
||||
const models = givenModels || own.models
|
||||
const loading = givenModels ? !!givenLoading : own.loading
|
||||
const names = useMemo(() => models.map(m => m.id), [models])
|
||||
const instanceId = useId()
|
||||
const menuId = `${instanceId}-menu`
|
||||
const listId = `${instanceId}-list`
|
||||
// A null row is a non-model action, never an installed model ID.
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const rootRef = useRef(null)
|
||||
const chipRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
const filterRef = useRef(null)
|
||||
|
||||
// Same defaulting as ModelSelector: the remembered model if it is still
|
||||
// there, otherwise the first one. Auto-select is not a user choice, so it is
|
||||
// not written back.
|
||||
useEffect(() => {
|
||||
if (autoSelect && !defaultSelected && names.length > 0 && (!value || !names.includes(value))) {
|
||||
const preferred = readPreferredModel(capability)
|
||||
const remembered = preferred && names.includes(preferred) ? preferred : readLastModel(capability)
|
||||
onChange(remembered && names.includes(remembered) ? remembered : names[0])
|
||||
}
|
||||
}, [names, value, onChange, capability, autoSelect, defaultSelected])
|
||||
|
||||
// Loaded models first when the list is grouped; each group keeps its order.
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const found = q ? names.filter(n => n.toLowerCase().includes(q)) : names
|
||||
const ordered = grouped ? [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))] : found
|
||||
return defaultChoice && (!q || defaultChoice.toLowerCase().includes(q)) ? [null, ...ordered] : ordered
|
||||
}, [names, query, grouped, loadedIds, defaultChoice])
|
||||
|
||||
const close = useCallback((restoreFocus = false) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
if (restoreFocus) chipRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
if (disabled || (names.length === 0 && !defaultChoice)) return
|
||||
setActive(defaultSelected ? 0 : Math.max(0, names.indexOf(value) + (defaultChoice ? 1 : 0)))
|
||||
setQuery('')
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [disabled, names, value, onOpen, defaultChoice, defaultSelected])
|
||||
|
||||
useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, close])
|
||||
|
||||
// Focus moves into the menu so the arrow keys work, and back to the chip when
|
||||
// it closes by keyboard.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const el = filterRef.current || menuRef.current
|
||||
el?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
menuRef.current?.querySelector('[data-active="true"]')?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [open, active, shown])
|
||||
|
||||
const pick = useCallback((name) => {
|
||||
if (name === null) onChooseDefault?.()
|
||||
else {
|
||||
if (rememberSelection) writeLastModel(capability, name)
|
||||
onChange(name)
|
||||
}
|
||||
close(true)
|
||||
}, [capability, onChange, close, rememberSelection, onChooseDefault])
|
||||
|
||||
const onMenuKey = (e) => {
|
||||
const n = shown.length
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive(a => (a + 1) % n) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) }
|
||||
else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) }
|
||||
else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); if (active < n) pick(shown[active]) }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
|
||||
else if (e.key === 'Tab') close()
|
||||
}
|
||||
|
||||
const isWarm = (name) => loadedIds?.has(name)
|
||||
const label = (defaultSelected ? defaultChoice : value) || placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none')))
|
||||
const activeId = active < shown.length ? `${instanceId}-opt-${active}` : undefined
|
||||
|
||||
return (
|
||||
<div className="home-pick" ref={rootRef}>
|
||||
<button
|
||||
ref={chipRef}
|
||||
id={id}
|
||||
aria-label={labels?.label}
|
||||
aria-describedby={[labels?.label && `${instanceId}-selection`, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
type="button"
|
||||
className="home-chip home-chip--model"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
title={labels?.title || t('picker.title')}
|
||||
data-testid="home-model-chip"
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
|
||||
>
|
||||
<Icon name="cube" />
|
||||
{showWarm && !defaultSelected && <span className={`home-dot${value && isWarm(value) ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
<span id={labels?.label ? `${instanceId}-selection` : undefined} className="home-chip__text">{label}</span>
|
||||
<Icon name="chevron-down" className="home-chip__caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="home-menu home-menu--models" id={menuId} onKeyDown={onMenuKey}>
|
||||
{names.length >= FILTER_FROM && (
|
||||
<input
|
||||
ref={filterRef}
|
||||
type="search"
|
||||
className="home-menu__filter"
|
||||
placeholder={t('picker.filter')}
|
||||
aria-label={t('picker.filter')}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId}
|
||||
/>
|
||||
)}
|
||||
<ul
|
||||
ref={menuRef}
|
||||
id={listId}
|
||||
className="home-menu__list"
|
||||
role="listbox"
|
||||
aria-label={labels?.label || t('picker.label')}
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={activeId}
|
||||
>
|
||||
{!grouped && <li className="home-menu__head" role="presentation">{labels?.heading || t('picker.heading')}</li>}
|
||||
{shown.length === 0 && <li className="home-menu__empty" role="presentation">{t('picker.noMatch')}</li>}
|
||||
{shown.map((name, i) => {
|
||||
const warm = isWarm(name)
|
||||
const info = name !== null && describe ? describe(name, warm) : null
|
||||
const selected = name === null ? defaultSelected : !defaultSelected && name === value
|
||||
const first = grouped && (i === 0 || isWarm(shown[i - 1]) !== warm)
|
||||
return (
|
||||
<Fragment key={name === null ? `${instanceId}-default` : name}>
|
||||
{first && (
|
||||
<li className="home-menu__head" role="presentation">
|
||||
{warm ? t('picker.loadedNow') : t('picker.installed')}
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
id={`${instanceId}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-active={i === active}
|
||||
className={`home-menu__item${info ? ' home-menu__item--rich' : ''}`}
|
||||
onMouseMove={() => setActive(i)}
|
||||
onClick={() => pick(name)}
|
||||
>
|
||||
{showWarm && name !== null && <span className={`home-dot${warm ? '' : ' home-dot--cold'}`} aria-hidden="true" />}
|
||||
{info ? (
|
||||
<>
|
||||
<span className="home-menu__main">
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{(info.size || info.vision) && (
|
||||
<small className="home-menu__caps">
|
||||
{info.size && <span>{info.size}</span>}
|
||||
{info.vision && <Icon name="eye" title={t('picker.vision')} />}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span className="home-menu__state" data-tone={info.fit?.tone}>
|
||||
<b>{selected ? <Icon name="check" className="home-menu__check" /> : null}{warm ? t('picker.warm') : t('picker.cold')}</b>
|
||||
{info.fit && <small>{info.fit.text}</small>}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<code>{name === null ? defaultChoice : name}</code>
|
||||
{selected
|
||||
? <Icon name="check" className="home-menu__check" />
|
||||
: (showWarm && name !== null && <small>{warm ? t('picker.warm') : t('picker.cold')}</small>)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default HomeModelPicker
|
||||
@@ -265,3 +265,19 @@
|
||||
.home-send-btn:active:not(:disabled),
|
||||
.home-send-btn:hover:not(:disabled) .home-send-btn__glyph { transform: none; }
|
||||
}
|
||||
|
||||
/* Shared model picker: form-field variant, alias and state tags, multi chips */
|
||||
.home-pick--field { width: 100%; }
|
||||
.home-pick--field .home-pick__row { display: flex; gap: var(--sp-1); }
|
||||
.home-chip--field { flex: 1; width: 100%; justify-content: flex-start; height: var(--ctl-h); }
|
||||
.home-chip--field .home-chip__caret { margin-left: auto; }
|
||||
.home-chip__alias { color: var(--dk-muted); font-size: var(--fs-meta); white-space: nowrap; }
|
||||
.home-pick__chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0 0 var(--sp-1); padding: 0; list-style: none; }
|
||||
.home-pick__tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px 2px 10px; border-radius: var(--r-md); background: var(--dk-hover); }
|
||||
.home-pick__tag code { font-family: var(--font-mono); font-size: var(--fs-small); background: none; padding: 0; }
|
||||
.home-pick__remove { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dk-muted); cursor: pointer; padding: 4px; border-radius: var(--r-sm); }
|
||||
.home-pick__remove:hover:not(:disabled) { background: var(--dk-hover); color: var(--dk-text); }
|
||||
.home-menu__tag { flex: none; padding: 0 6px; border-radius: var(--r-sm); background: var(--dk-hover); color: var(--dk-muted); font-size: var(--fs-meta); white-space: nowrap; }
|
||||
.home-menu__tag[data-kind="alias"] { background: var(--dk-accent-wash); color: var(--dk-accent-text); }
|
||||
.home-menu__tag[data-kind="broken"] { color: var(--color-error, #c0392b); }
|
||||
.home-menu__item[data-blocked="true"] { opacity: .55; cursor: not-allowed; }
|
||||
@@ -14,7 +14,7 @@ import './agents.css'
|
||||
import './library.css'
|
||||
import '../components/home/composer.css'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import HomeModelPicker from '../components/home/HomeModelPicker'
|
||||
import ModelPicker from '../components/ModelPicker'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import NameList from '../components/ChatNameList'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -297,7 +297,7 @@ export default function AgentCreate() {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="agent-model">{t('create.model')}</label>
|
||||
<HomeModelPicker
|
||||
<ModelPicker
|
||||
id="agent-model"
|
||||
labels={{ label: t('create.model') }}
|
||||
autoSelect={false}
|
||||
|
||||
Reference in new issue
Block a user