mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 13:15:51 -04:00
feat(ui, gallery): Show model backends and add searchable model/backend selector (#9060)
* feat(ui, gallery): Display and filter by the backend models use Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(ui): Add searchable model backend/model selector and prevent delete models being selected Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
e832efeb9e
commit
cfb7641eea
173
core/gallery/backend_resolve.go
Normal file
173
core/gallery/backend_resolve.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/downloader"
|
||||
"github.com/mudler/LocalAI/pkg/xsync"
|
||||
"github.com/mudler/xlog"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// modelConfigCacheEntry holds a cached parsed config_file map from a URL-referenced model config.
|
||||
type modelConfigCacheEntry struct {
|
||||
configMap map[string]interface{}
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
func (e modelConfigCacheEntry) hasExpired() bool {
|
||||
return e.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
|
||||
}
|
||||
|
||||
// modelConfigCache caches parsed model config maps keyed by URL.
|
||||
var modelConfigCache = xsync.NewSyncedMap[string, modelConfigCacheEntry]()
|
||||
|
||||
// resolveBackend determines the backend for a GalleryModel by checking (in priority order):
|
||||
// 1. Overrides["backend"] — highest priority, same as install-time merge
|
||||
// 2. Inline ConfigFile["backend"] — for models with inline config maps
|
||||
// 3. URL-referenced config file — fetched, parsed, and cached
|
||||
//
|
||||
// The model's URL should already be resolved (local override applied) before calling this.
|
||||
func resolveBackend(m *GalleryModel, basePath string) string {
|
||||
// 1. Overrides take priority (matches install-time mergo.WithOverride behavior)
|
||||
if b, ok := m.Overrides["backend"].(string); ok && b != "" {
|
||||
return b
|
||||
}
|
||||
|
||||
// 2. Inline config_file map
|
||||
if b, ok := m.ConfigFile["backend"].(string); ok && b != "" {
|
||||
return b
|
||||
}
|
||||
|
||||
// 3. Fetch and parse the URL-referenced config
|
||||
if m.URL != "" {
|
||||
configMap := fetchModelConfigMap(m.URL, basePath)
|
||||
if b, ok := configMap["backend"].(string); ok && b != "" {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchModelConfigMap fetches a model config URL, parses the config_file YAML string
|
||||
// inside it, and returns the result as a map. Results are cached for 1 hour.
|
||||
// Local file:// URLs skip the cache so edits are picked up immediately.
|
||||
func fetchModelConfigMap(modelURL, basePath string) map[string]interface{} {
|
||||
// Check cache (skip for file:// URLs so local edits are picked up immediately)
|
||||
isLocal := strings.HasPrefix(modelURL, downloader.LocalPrefix)
|
||||
if !isLocal && modelConfigCache.Exists(modelURL) {
|
||||
entry := modelConfigCache.Get(modelURL)
|
||||
if !entry.hasExpired() {
|
||||
return entry.configMap
|
||||
}
|
||||
modelConfigCache.Delete(modelURL)
|
||||
}
|
||||
|
||||
// Reuse existing gallery config fetcher
|
||||
modelConfig, err := GetGalleryConfigFromURL[ModelConfig](modelURL, basePath)
|
||||
if err != nil {
|
||||
xlog.Debug("Failed to fetch model config for backend resolution", "url", modelURL, "error", err)
|
||||
// Cache the failure for remote URLs to avoid repeated fetch attempts
|
||||
if !isLocal {
|
||||
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
|
||||
configMap: map[string]interface{}{},
|
||||
lastUpdated: time.Now(),
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Parse the config_file YAML string into a map
|
||||
configMap := make(map[string]interface{})
|
||||
if modelConfig.ConfigFile != "" {
|
||||
if err := yaml.Unmarshal([]byte(modelConfig.ConfigFile), &configMap); err != nil {
|
||||
xlog.Debug("Failed to parse config_file for backend resolution", "url", modelURL, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for remote URLs
|
||||
if !isLocal {
|
||||
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
|
||||
configMap: configMap,
|
||||
lastUpdated: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
return configMap
|
||||
}
|
||||
|
||||
// prefetchModelConfigs fetches model config URLs in parallel to warm the cache.
|
||||
// This avoids sequential HTTP requests on cold start (~50 unique gallery files).
|
||||
func prefetchModelConfigs(urls []string, basePath string) {
|
||||
const maxConcurrency = 10
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, url := range urls {
|
||||
wg.Add(1)
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
fetchModelConfigMap(u, basePath)
|
||||
}(url)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// resolveModelURLLocally attempts to resolve a github: model URL to a local file://
|
||||
// path when the gallery itself was loaded from a local path. This supports development
|
||||
// workflows where new model files are added locally before being pushed to GitHub.
|
||||
//
|
||||
// For example, if the gallery was loaded from file:///path/to/gallery/index.yaml
|
||||
// and a model references github:mudler/LocalAI/gallery/foo.yaml@master, this will
|
||||
// check if /path/to/gallery/foo.yaml exists locally and return file:///path/to/gallery/foo.yaml.
|
||||
//
|
||||
// This is applied to model.URL in AvailableGalleryModels so that both listing (backend
|
||||
// resolution) and installation use the same resolved URL.
|
||||
func resolveModelURLLocally(modelURL, galleryURL string) string {
|
||||
galleryDir := localGalleryDir(galleryURL)
|
||||
if galleryDir == "" {
|
||||
return modelURL
|
||||
}
|
||||
|
||||
// Only handle github: URLs
|
||||
if !strings.HasPrefix(modelURL, downloader.GithubURI) && !strings.HasPrefix(modelURL, downloader.GithubURI2) {
|
||||
return modelURL
|
||||
}
|
||||
|
||||
// Extract the filename from the github URL
|
||||
// Format: github:org/repo/path/to/file.yaml@branch
|
||||
raw := strings.TrimPrefix(modelURL, downloader.GithubURI2)
|
||||
raw = strings.TrimPrefix(raw, downloader.GithubURI)
|
||||
// Remove @branch suffix
|
||||
if idx := strings.LastIndex(raw, "@"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
filename := filepath.Base(raw)
|
||||
|
||||
localPath := filepath.Join(galleryDir, filename)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return downloader.LocalPrefix + localPath
|
||||
}
|
||||
|
||||
return modelURL
|
||||
}
|
||||
|
||||
// localGalleryDir returns the directory of a gallery URL if it's local, or "" if remote.
|
||||
func localGalleryDir(galleryURL string) string {
|
||||
if strings.HasPrefix(galleryURL, downloader.LocalPrefix) {
|
||||
return filepath.Dir(strings.TrimPrefix(galleryURL, downloader.LocalPrefix))
|
||||
}
|
||||
// Plain path (no scheme) that exists on disk
|
||||
if !strings.Contains(galleryURL, "://") && !strings.HasPrefix(galleryURL, downloader.GithubURI) {
|
||||
if info, err := os.Stat(galleryURL); err == nil && !info.IsDir() {
|
||||
return filepath.Dir(galleryURL)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -218,6 +218,36 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve model URLs locally (for local galleries) and collect unique
|
||||
// URLs that need fetching for backend resolution.
|
||||
uniqueURLs := map[string]struct{}{}
|
||||
for _, m := range galleryModels {
|
||||
if m.URL != "" {
|
||||
m.URL = resolveModelURLLocally(m.URL, gallery.URL)
|
||||
}
|
||||
if m.Backend == "" && m.URL != "" {
|
||||
uniqueURLs[m.URL] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-warm cache with parallel fetches to avoid sequential HTTP
|
||||
// requests on cold start (~50 unique gallery config files).
|
||||
if len(uniqueURLs) > 0 {
|
||||
urls := make([]string, 0, len(uniqueURLs))
|
||||
for u := range uniqueURLs {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
prefetchModelConfigs(urls, systemState.Model.ModelsPath)
|
||||
}
|
||||
|
||||
// Resolve backends from warm cache.
|
||||
for _, m := range galleryModels {
|
||||
if m.Backend == "" {
|
||||
m.Backend = resolveBackend(m, systemState.Model.ModelsPath)
|
||||
}
|
||||
}
|
||||
|
||||
models = append(models, galleryModels...)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,7 @@ type Metadata struct {
|
||||
Gallery config.Gallery `json:"gallery,omitempty" yaml:"gallery,omitempty"`
|
||||
// Installed is used to indicate if the model is installed or not
|
||||
Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"`
|
||||
// Backend is the resolved backend engine for this model (e.g. "llama-cpp").
|
||||
// Populated at load time from overrides, inline config, or the URL-referenced config file.
|
||||
Backend string `json:"backend,omitempty" yaml:"backend,omitempty"`
|
||||
}
|
||||
|
||||
80
core/http/react-ui/e2e/models-gallery.spec.js
Normal file
80
core/http/react-ui/e2e/models-gallery.spec.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const MOCK_MODELS_RESPONSE = {
|
||||
models: [
|
||||
{ name: 'llama-model', description: 'A llama model', backend: 'llama-cpp', installed: false, tags: ['llm'] },
|
||||
{ name: 'whisper-model', description: 'A whisper model', backend: 'whisper', installed: true, tags: ['stt'] },
|
||||
{ name: 'stablediffusion-model', description: 'An image model', backend: 'stablediffusion', installed: false, tags: ['sd'] },
|
||||
{ name: 'unknown-model', description: 'No backend', backend: '', installed: false, tags: [] },
|
||||
],
|
||||
allBackends: ['llama-cpp', 'stablediffusion', 'whisper'],
|
||||
allTags: ['llm', 'sd', 'stt'],
|
||||
availableModels: 4,
|
||||
installedModels: 1,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
}
|
||||
|
||||
test.describe('Models Gallery - Backend Features', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/models*', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(MOCK_MODELS_RESPONSE),
|
||||
})
|
||||
})
|
||||
await page.goto('/app/models')
|
||||
// Wait for the table to render
|
||||
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('backend column header is visible', async ({ page }) => {
|
||||
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('backend badges shown in table rows', async ({ page }) => {
|
||||
const table = page.locator('table')
|
||||
await expect(table.locator('.badge', { hasText: 'llama-cpp' })).toBeVisible()
|
||||
await expect(table.locator('.badge', { hasText: /^whisper$/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test('backend dropdown is visible', async ({ page }) => {
|
||||
await expect(page.locator('button', { hasText: 'All Backends' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking backend dropdown opens searchable panel', async ({ page }) => {
|
||||
await page.locator('button', { hasText: 'All Backends' }).click()
|
||||
await expect(page.locator('input[placeholder="Search backends..."]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('typing in search filters dropdown options', async ({ page }) => {
|
||||
await page.locator('button', { hasText: 'All Backends' }).click()
|
||||
const searchInput = page.locator('input[placeholder="Search backends..."]')
|
||||
await searchInput.fill('llama')
|
||||
|
||||
// llama-cpp option should be visible, whisper should not
|
||||
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..') .locator('..')
|
||||
await expect(dropdown.locator('text=llama-cpp')).toBeVisible()
|
||||
await expect(dropdown.locator('text=whisper')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting a backend updates the dropdown label', async ({ page }) => {
|
||||
await page.locator('button', { hasText: 'All Backends' }).click()
|
||||
// Click the llama-cpp option within the dropdown (not the table badge)
|
||||
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..')
|
||||
await dropdown.locator('text=llama-cpp').click()
|
||||
|
||||
// The dropdown button should now show the selected backend instead of "All Backends"
|
||||
await expect(page.locator('button span', { hasText: 'llama-cpp' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('expanded row shows backend in detail', async ({ page }) => {
|
||||
// Click the first model row to expand it
|
||||
await page.locator('tr', { hasText: 'llama-model' }).click()
|
||||
|
||||
// The detail view should show Backend label and value
|
||||
const detail = page.locator('td[colspan="8"]')
|
||||
await expect(detail.locator('text=Backend')).toBeVisible()
|
||||
await expect(detail.locator('text=llama-cpp')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : {
|
||||
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089',
|
||||
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1',
|
||||
port: 8089,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
|
||||
@@ -1828,7 +1828,7 @@
|
||||
.chat-mcp-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
max-height: 320px;
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useModels } from '../hooks/useModels'
|
||||
import SearchableSelect from './SearchableSelect'
|
||||
|
||||
export default function ModelSelector({ value, onChange, capability, className = '' }) {
|
||||
const { models, loading } = useModels(capability)
|
||||
export default function ModelSelector({
|
||||
value, onChange, capability, className = '',
|
||||
options: externalOptions, loading: externalLoading,
|
||||
disabled: externalDisabled, searchPlaceholder, style,
|
||||
}) {
|
||||
// Skip capability fetch when external options are provided (capability will be undefined)
|
||||
const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
|
||||
|
||||
const modelNames = useMemo(
|
||||
() => externalOptions || hookModels.map(m => m.id),
|
||||
[externalOptions, hookModels]
|
||||
)
|
||||
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
|
||||
const isDisabled = isLoading || (externalDisabled || false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value && models.length > 0) {
|
||||
onChange(models[0].id)
|
||||
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
|
||||
onChange(modelNames[0])
|
||||
}
|
||||
}, [models, value, onChange])
|
||||
}, [modelNames, value, onChange])
|
||||
|
||||
return (
|
||||
<select
|
||||
className={`model-selector ${className}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <option>Loading models...</option>}
|
||||
{!loading && models.length === 0 && <option>No models available</option>}
|
||||
{models.map(model => (
|
||||
<option key={model.id} value={model.id}>{model.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
options={modelNames}
|
||||
placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')}
|
||||
searchPlaceholder={searchPlaceholder || 'Search models...'}
|
||||
disabled={isDisabled}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
170
core/http/react-ui/src/components/SearchableSelect.jsx
Normal file
170
core/http/react-ui/src/components/SearchableSelect.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
export default function SearchableSelect({
|
||||
value, onChange, options, placeholder = 'Select...',
|
||||
allOption, searchPlaceholder = 'Search...',
|
||||
disabled = false, style, className = '',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [focusIndex, setFocusIndex] = useState(-1)
|
||||
const ref = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
const listRef = useRef(null)
|
||||
|
||||
const items = useMemo(() =>
|
||||
options.map(o => typeof o === 'string' ? { value: o, label: o } : o),
|
||||
[options]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const filtered = query
|
||||
? items.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: items
|
||||
|
||||
// Determine which item Enter will select
|
||||
const enterTarget = focusIndex >= 0
|
||||
? { type: 'item', index: focusIndex }
|
||||
: filtered.length > 0
|
||||
? { type: 'item', index: 0 }
|
||||
: allOption
|
||||
? { type: 'all' }
|
||||
: null
|
||||
|
||||
const select = (val) => {
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setFocusIndex(-1)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setFocusIndex(i => Math.min(i + 1, filtered.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setFocusIndex(i => Math.max(i - 1, -1))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (!enterTarget) return
|
||||
if (enterTarget.type === 'all') {
|
||||
select('')
|
||||
} else {
|
||||
select(filtered[enterTarget.index].value)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setFocusIndex(-1)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll focused item into view
|
||||
useEffect(() => {
|
||||
if (focusIndex >= 0 && listRef.current) {
|
||||
const offset = allOption ? focusIndex + 1 : focusIndex
|
||||
const item = listRef.current.children[offset]
|
||||
if (item) item.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [focusIndex, allOption])
|
||||
|
||||
const displayLabel = !value ? placeholder
|
||||
: (items.find(o => o.value === value)?.label ?? value)
|
||||
|
||||
const itemStyle = (isActive, isFocused) => ({
|
||||
padding: '6px 10px', fontSize: '0.8125rem', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
color: isActive ? 'var(--color-primary)' : 'var(--color-text-primary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
background: isFocused ? 'var(--color-bg-tertiary)' : (isActive ? 'var(--color-bg-tertiary)' : 'transparent'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ position: 'relative', minWidth: 160, ...style }}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="input"
|
||||
onClick={() => { if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
|
||||
style={{
|
||||
width: '100%', padding: '4px 8px', fontSize: '0.8125rem',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, textAlign: 'left' }}>{displayLabel}</span>
|
||||
<i className="fas fa-chevron-down" style={{ fontSize: '0.5rem', color: 'var(--color-text-muted)' }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, marginTop: 4,
|
||||
minWidth: 200, maxHeight: 260, background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ padding: '6px', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<input
|
||||
autoFocus
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setFocusIndex(-1) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} style={{ overflowY: 'auto', maxHeight: 200 }}>
|
||||
{allOption && (
|
||||
<div
|
||||
onClick={() => select('')}
|
||||
style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')}
|
||||
onMouseEnter={() => setFocusIndex(-1)}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{allOption}</span>
|
||||
{enterTarget?.type === 'all' && (
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>↵</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((o, i) => {
|
||||
const isActive = value === o.value
|
||||
const isEnterTarget = enterTarget?.type === 'item' && enterTarget.index === i
|
||||
const isFocused = focusIndex === i || isEnterTarget
|
||||
return (
|
||||
<div
|
||||
key={o.value}
|
||||
onClick={() => select(o.value)}
|
||||
style={itemStyle(isActive, isFocused)}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{o.label}</span>
|
||||
{isEnterTarget && (
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>↵</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && !allOption && (
|
||||
<div style={{ padding: '6px 10px', fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -837,32 +837,6 @@ export default function Chat() {
|
||||
<i className={`fas fa-${sidebarOpen ? 'angles-left' : 'angles-right'}`} />
|
||||
</button>
|
||||
<span className="chat-header-title">{activeChat.name}</span>
|
||||
<ModelSelector
|
||||
value={activeChat.model}
|
||||
onChange={(model) => updateChatSettings(activeChat.id, { model })}
|
||||
capability="FLAG_CHAT"
|
||||
/>
|
||||
{activeChat.model && (
|
||||
<>
|
||||
{modelInfo?.backend && (
|
||||
<span className="badge badge-info" style={{ fontSize: '0.75rem' }}>{modelInfo.backend}</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowModelInfo(!showModelInfo)}
|
||||
title="Model info"
|
||||
>
|
||||
<i className="fas fa-info-circle" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
|
||||
title="Edit model config"
|
||||
>
|
||||
<i className="fas fa-edit" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<UnifiedMCPDropdown
|
||||
serverMCPAvailable={mcpAvailable}
|
||||
mcpServerList={mcpServerList}
|
||||
@@ -898,7 +872,31 @@ export default function Chat() {
|
||||
selectedResources={activeChat.mcpResources || []}
|
||||
onToggleResource={toggleMcpResource}
|
||||
/>
|
||||
<ModelSelector
|
||||
value={activeChat.model}
|
||||
onChange={(model) => updateChatSettings(activeChat.id, { model })}
|
||||
capability="FLAG_CHAT"
|
||||
style={{ flex: '1 1 0', minWidth: 120 }}
|
||||
/>
|
||||
<div className="chat-header-actions">
|
||||
{activeChat.model && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowModelInfo(!showModelInfo)}
|
||||
title="Model info"
|
||||
>
|
||||
<i className="fas fa-info-circle" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
|
||||
title="Edit model config"
|
||||
>
|
||||
<i className="fas fa-edit" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">
|
||||
<i className="fas fa-columns" />
|
||||
<span className="canvas-mode-label">Canvas</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import CodeEditor from '../components/CodeEditor'
|
||||
import SearchableSelect from '../components/SearchableSelect'
|
||||
|
||||
const BACKENDS = [
|
||||
{ value: '', label: 'Auto-detect (based on URI)' },
|
||||
@@ -321,9 +322,15 @@ export default function ImportModel() {
|
||||
<div style={{ display: 'grid', gap: 'var(--spacing-md)' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label"><i className="fas fa-server" style={{ marginRight: '6px' }} />Backend</label>
|
||||
<select className="input" value={prefs.backend} onChange={e => updatePref('backend', e.target.value)} disabled={isSubmitting}>
|
||||
{BACKENDS.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={prefs.backend}
|
||||
onChange={(v) => updatePref('backend', v)}
|
||||
options={BACKENDS.filter(b => b.value !== '')}
|
||||
allOption="Auto-detect (based on URI)"
|
||||
placeholder="Auto-detect (based on URI)"
|
||||
searchPlaceholder="Search backends..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p style={hintStyle}>Force a specific backend. Leave empty to auto-detect from URI.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
import SearchableSelect from '../components/SearchableSelect'
|
||||
import React from 'react'
|
||||
|
||||
|
||||
@@ -108,6 +109,7 @@ function GalleryLoader() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const FILTERS = [
|
||||
{ key: '', label: 'All', icon: 'fa-layer-group' },
|
||||
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
|
||||
@@ -137,6 +139,8 @@ export default function Models() {
|
||||
const [expandedRow, setExpandedRow] = useState(null)
|
||||
const [expandedFiles, setExpandedFiles] = useState(false)
|
||||
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
|
||||
const [backendFilter, setBackendFilter] = useState('')
|
||||
const [allBackends, setAllBackends] = useState([])
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
// Total GPU memory for "fits" check
|
||||
@@ -148,6 +152,7 @@ export default function Models() {
|
||||
const searchVal = params.search !== undefined ? params.search : search
|
||||
const filterVal = params.filter !== undefined ? params.filter : filter
|
||||
const sortVal = params.sort !== undefined ? params.sort : sort
|
||||
const backendVal = params.backendFilter !== undefined ? params.backendFilter : backendFilter
|
||||
// Combine search text and filter into 'term' param
|
||||
const term = searchVal || filterVal || ''
|
||||
const queryParams = {
|
||||
@@ -155,6 +160,7 @@ export default function Models() {
|
||||
items: 9,
|
||||
}
|
||||
if (term) queryParams.term = term
|
||||
if (backendVal) queryParams.backend = backendVal
|
||||
if (sortVal) {
|
||||
queryParams.sort = sortVal
|
||||
queryParams.order = params.order || order
|
||||
@@ -166,16 +172,17 @@ export default function Models() {
|
||||
total: data?.availableModels || 0,
|
||||
installed: data?.installedModels || 0,
|
||||
})
|
||||
setAllBackends(data?.allBackends || [])
|
||||
} catch (err) {
|
||||
addToast(`Failed to load models: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, filter, sort, order, addToast])
|
||||
}, [page, search, filter, sort, order, backendFilter, addToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
}, [page, filter, sort, order])
|
||||
}, [page, filter, sort, order, backendFilter])
|
||||
|
||||
// Re-fetch when operations change (install/delete completion)
|
||||
useEffect(() => {
|
||||
@@ -305,6 +312,17 @@ export default function Models() {
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
{allBackends.length > 0 && (
|
||||
<SearchableSelect
|
||||
value={backendFilter}
|
||||
onChange={(v) => { setBackendFilter(v); setPage(1) }}
|
||||
options={allBackends}
|
||||
placeholder="All Backends"
|
||||
allOption="All Backends"
|
||||
searchPlaceholder="Search backends..."
|
||||
style={{ marginLeft: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -328,6 +346,7 @@ export default function Models() {
|
||||
Model Name {sort === 'name' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
|
||||
</th>
|
||||
<th>Description</th>
|
||||
<th>Backend</th>
|
||||
<th>Size / VRAM</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
|
||||
Status {sort === 'status' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
|
||||
@@ -393,6 +412,17 @@ export default function Models() {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Backend */}
|
||||
<td>
|
||||
{model.backend ? (
|
||||
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
|
||||
{model.backend}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Size / VRAM */}
|
||||
<td>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
@@ -476,7 +506,7 @@ export default function Models() {
|
||||
{/* Expanded detail row */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan="7" style={{ padding: 0 }}>
|
||||
<td colSpan="8" style={{ padding: 0 }}>
|
||||
<ModelDetail model={model} fit={fit} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -539,6 +569,13 @@ function ModelDetail({ model, fit, expandedFiles, setExpandedFiles }) {
|
||||
</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Backend">
|
||||
{model.backend && (
|
||||
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
|
||||
{model.backend}
|
||||
</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Size">
|
||||
{model.estimated_size_display && model.estimated_size_display !== '0 B' ? model.estimated_size_display : null}
|
||||
</DetailRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { realtimeApi } from '../utils/api'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
|
||||
const STATUS_STYLES = {
|
||||
disconnected: { icon: 'fa-solid fa-circle', color: 'var(--color-text-secondary)', bg: 'transparent' },
|
||||
@@ -17,6 +18,7 @@ export default function Talk() {
|
||||
|
||||
// Pipeline models
|
||||
const [pipelineModels, setPipelineModels] = useState([])
|
||||
const pipelineModelNames = useMemo(() => pipelineModels.map(m => m.name), [pipelineModels])
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [modelsLoading, setModelsLoading] = useState(true)
|
||||
|
||||
@@ -482,23 +484,18 @@ export default function Talk() {
|
||||
<label className="form-label" style={{ fontSize: '0.8125rem' }}>
|
||||
<i className="fas fa-brain" style={{ color: 'var(--color-primary)', marginRight: 4 }} /> Pipeline Model
|
||||
</label>
|
||||
<select
|
||||
className="model-selector"
|
||||
<ModelSelector
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e.target.value)
|
||||
const m = pipelineModels.find(p => p.name === e.target.value)
|
||||
onChange={(v) => {
|
||||
setSelectedModel(v)
|
||||
const m = pipelineModels.find(p => p.name === v)
|
||||
if (m && !voiceEdited) setVoice(m.voice || '')
|
||||
}}
|
||||
disabled={modelsLoading || isConnected}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{modelsLoading && <option>Loading models...</option>}
|
||||
{!modelsLoading && pipelineModels.length === 0 && <option>No pipeline models available</option>}
|
||||
{pipelineModels.map(m => (
|
||||
<option key={m.name} value={m.name}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
options={pipelineModelNames}
|
||||
loading={modelsLoading}
|
||||
disabled={isConnected}
|
||||
searchPlaceholder="Search pipeline models..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pipeline details */}
|
||||
|
||||
@@ -239,10 +239,35 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
}
|
||||
sort.Strings(tags)
|
||||
|
||||
// Get all available backends (before filtering so dropdown always shows all)
|
||||
allBackendsMap := map[string]struct{}{}
|
||||
for _, m := range models {
|
||||
if b := m.Backend; b != "" {
|
||||
allBackendsMap[b] = struct{}{}
|
||||
}
|
||||
}
|
||||
backendNames := make([]string, 0, len(allBackendsMap))
|
||||
for b := range allBackendsMap {
|
||||
backendNames = append(backendNames, b)
|
||||
}
|
||||
sort.Strings(backendNames)
|
||||
|
||||
if term != "" {
|
||||
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
|
||||
}
|
||||
|
||||
// Filter by backend if requested
|
||||
backendFilter := c.QueryParam("backend")
|
||||
if backendFilter != "" {
|
||||
var filtered gallery.GalleryElements[*gallery.GalleryModel]
|
||||
for _, m := range models {
|
||||
if m.Backend == backendFilter {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
models = filtered
|
||||
}
|
||||
|
||||
// Get model statuses
|
||||
processingModelsData, taskTypes := opcache.GetStatus()
|
||||
|
||||
@@ -359,6 +384,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
"isDeletion": isDeletionOp,
|
||||
"trustRemoteCode": trustRemoteCodeExists,
|
||||
"additionalFiles": m.AdditionalFiles,
|
||||
"backend": m.Backend,
|
||||
}
|
||||
|
||||
if hasWeightFiles(m.AdditionalFiles) {
|
||||
@@ -449,6 +475,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
"models": modelsJSON,
|
||||
"repositories": appConfig.Galleries,
|
||||
"allTags": tags,
|
||||
"allBackends": backendNames,
|
||||
"processingModels": processingModelsData,
|
||||
"taskTypes": taskTypes,
|
||||
"availableModels": totalModels,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG GO_VERSION=1.25.4
|
||||
ARG PLAYWRIGHT_VERSION=v1.52.0
|
||||
ARG PLAYWRIGHT_VERSION=v1.58.2
|
||||
|
||||
###################################
|
||||
# Stage 1: Build React UI
|
||||
@@ -80,13 +80,17 @@ ENV CI=true
|
||||
ENV PLAYWRIGHT_EXTERNAL_SERVER=1
|
||||
|
||||
CMD ["bash", "-c", "\
|
||||
ui-test-server --mock-backend=/usr/local/bin/mock-backend --port=8089 & \
|
||||
ui-test-server --mock-backend=/usr/local/bin/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1 & \
|
||||
for i in $(seq 1 30); do \
|
||||
curl -sf http://127.0.0.1:8089/readyz > /dev/null 2>&1 && break; \
|
||||
sleep 1; \
|
||||
done && \
|
||||
npx playwright test; \
|
||||
TEST_EXIT=$?; \
|
||||
if [ $TEST_EXIT -ne 0 ]; then \
|
||||
echo '--- ui-test-server logs (last 50 lines) ---'; \
|
||||
tail -50 /tmp/ui-test-server.log; \
|
||||
fi; \
|
||||
kill %1 2>/dev/null; \
|
||||
exit $TEST_EXIT \
|
||||
"]
|
||||
|
||||
Reference in New Issue
Block a user