diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json index 02dab04ae3..0fffa69d35 100644 --- a/core/http/react-ui/public/locales/de/home.json +++ b/core/http/react-ui/public/locales/de/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index a3a5e4f6ea..2ceeb1b6c3 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -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", diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json index 8afe400e0f..3ab7c3897a 100644 --- a/core/http/react-ui/public/locales/es/home.json +++ b/core/http/react-ui/public/locales/es/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/fr/home.json b/core/http/react-ui/public/locales/fr/home.json index 663711b09e..ca7e4e48e4 100644 --- a/core/http/react-ui/public/locales/fr/home.json +++ b/core/http/react-ui/public/locales/fr/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json index b8573dbbc9..c3aaec60ef 100644 --- a/core/http/react-ui/public/locales/id/home.json +++ b/core/http/react-ui/public/locales/id/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json index 41c5e1fa53..49cca23994 100644 --- a/core/http/react-ui/public/locales/it/home.json +++ b/core/http/react-ui/public/locales/it/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json index 1d256d61b4..13eb6d92fc 100644 --- a/core/http/react-ui/public/locales/ko/home.json +++ b/core/http/react-ui/public/locales/ko/home.json @@ -139,5 +139,16 @@ }, "complete": "완성" }, - "notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." } + "notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." }, + "picker": { + "aliasOf": "{{target}}의 별칭", + "aliasBroken": "깨진 별칭", + "disabled": "비활성", + "pinned": "고정됨", + "useTyped": "\"{{name}}\" 사용", + "empty": "사용 가능한 모델 없음", + "add": "모델 추가", + "remove": "{{name}} 제거", + "clear": "지우기" + } } diff --git a/core/http/react-ui/public/locales/pt-BR/home.json b/core/http/react-ui/public/locales/pt-BR/home.json index daf7e8ddfa..21a37efa6c 100644 --- a/core/http/react-ui/public/locales/pt-BR/home.json +++ b/core/http/react-ui/public/locales/pt-BR/home.json @@ -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" + } } diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json index 6290fbf328..9111a54707 100644 --- a/core/http/react-ui/public/locales/zh-CN/home.json +++ b/core/http/react-ui/public/locales/zh-CN/home.json @@ -139,5 +139,16 @@ }, "complete": "补全" }, - "notice": { "appUnavailable": "应用已不可用。" } + "notice": { "appUnavailable": "应用已不可用。" }, + "picker": { + "aliasOf": "{{target}} 的别名", + "aliasBroken": "别名失效", + "disabled": "已停用", + "pinned": "已固定", + "useTyped": "使用 \"{{name}}\"", + "empty": "没有可用模型", + "add": "添加模型", + "remove": "移除 {{name}}", + "clear": "清除" + } } diff --git a/core/http/react-ui/src/addons/hostServices.js b/core/http/react-ui/src/addons/hostServices.js index e0d3fedf25..45cb7bb274 100644 --- a/core/http/react-ui/src/addons/hostServices.js +++ b/core/http/react-ui/src/addons/hostServices.js @@ -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, diff --git a/core/http/react-ui/src/components/ModelPicker.jsx b/core/http/react-ui/src/components/ModelPicker.jsx new file mode 100644 index 0000000000..24f47959d9 --- /dev/null +++ b/core/http/react-ui/src/components/ModelPicker.jsx @@ -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 && {t('picker.aliasOf', { target: m.alias_of })}} + {m.alias_error && {t('picker.aliasBroken')}} + {m.disabled && {t('picker.disabled')}} + {m.pinned && {t('picker.pinned')}} + > + ) + } + + return ( +
{name}
+
+