fix(react-ui): stop Manage page from blanking on auto-refresh; show real model use cases

- useModels.refetch now runs silently — distributed-mode 10s auto-refresh
  no longer flips loading=true and replaces the table with a spinner card.
- Manage Use Cases column derives badges from each model's actual
  capabilities (Chat / Image / TTS / Embeddings / etc.) instead of
  hardcoding a "Chat" link for every row.
- FilterBar right slot is right-aligned via margin-left:auto so the
  Update button lives at the end of the row, not next to the chips.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-7 [Claude Code]
This commit is contained in:
Ettore Di Giacinto
2026-04-26 19:34:54 +00:00
parent d9cb0d6133
commit c8d63a1003
3 changed files with 49 additions and 5 deletions

View File

@@ -1807,6 +1807,7 @@ select.input {
flex-wrap: wrap;
padding-left: var(--spacing-md);
border-left: 1px solid var(--color-border-subtle);
margin-left: auto;
}
.filter-bar-group__toggle {
display: flex;

View File

@@ -6,9 +6,9 @@ export function useModels(capability) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchModels = useCallback(async () => {
const fetchModels = useCallback(async ({ silent = false } = {}) => {
try {
setLoading(true)
if (!silent) setLoading(true)
const data = await modelsApi.listCapabilities()
let items = data?.data || []
if (capability) {
@@ -30,15 +30,19 @@ export function useModels(capability) {
setError(err.message)
}
} finally {
setLoading(false)
if (!silent) setLoading(false)
}
}, [capability])
// Subsequent refetches stay silent so consumers don't blank their tables
// (e.g. the Manage page auto-refreshes every 10s in distributed mode).
const refetch = useCallback(() => fetchModels({ silent: true }), [fetchModels])
useEffect(() => {
fetchModels()
}, [fetchModels])
return { models, loading, error, refetch: fetchModels }
return { models, loading, error, refetch }
}
export function useGalleryModels(params = {}) {

View File

@@ -7,12 +7,34 @@ import NodeDistributionChip from '../components/NodeDistributionChip'
import FilterBar from '../components/FilterBar'
import { useModels } from '../hooks/useModels'
import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api'
import {
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
CAP_TRANSCRIPT, CAP_SOUND_GENERATION, CAP_FACE_RECOGNITION,
CAP_SPEAKER_RECOGNITION, CAP_EMBEDDINGS, CAP_RERANK,
} from '../utils/capabilities'
const TABS = [
{ key: 'models', label: 'Models', icon: 'fa-brain' },
{ key: 'backends', label: 'Backends', icon: 'fa-server' },
]
// Capability → use-case badge. Entries with `route` become clickable links to
// the matching playground page; the rest render as informational badges.
// Order is the display order. CAP_CHAT covers CAP_COMPLETION too.
const USE_CASES = [
{ cap: CAP_CHAT, label: 'Chat', route: (id) => `/app/chat/${encodeURIComponent(id)}` },
{ cap: CAP_COMPLETION, label: 'Completion', route: (id) => `/app/chat/${encodeURIComponent(id)}`, hideIf: CAP_CHAT },
{ cap: CAP_IMAGE, label: 'Image', route: (id) => `/app/image/${encodeURIComponent(id)}` },
{ cap: CAP_VIDEO, label: 'Video', route: (id) => `/app/video/${encodeURIComponent(id)}` },
{ cap: CAP_TTS, label: 'TTS', route: (id) => `/app/tts/${encodeURIComponent(id)}` },
{ cap: CAP_TRANSCRIPT, label: 'Transcribe', route: () => '/app/talk' },
{ cap: CAP_SOUND_GENERATION, label: 'Sound', route: (id) => `/app/sound/${encodeURIComponent(id)}` },
{ cap: CAP_FACE_RECOGNITION, label: 'Face', route: (id) => `/app/face/${encodeURIComponent(id)}` },
{ cap: CAP_SPEAKER_RECOGNITION, label: 'Voice', route: (id) => `/app/voice/${encodeURIComponent(id)}` },
{ cap: CAP_EMBEDDINGS, label: 'Embeddings' },
{ cap: CAP_RERANK, label: 'Rerank' },
]
// formatInstalledAt renders an installed_at timestamp as a short relative/abs
// string suitable for dense tables. Returns the raw value if parsing fails so
// we never display "Invalid Date".
@@ -503,7 +525,24 @@ export default function Manage() {
{/* Use Cases */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', flexWrap: 'wrap' }}>
<a href="#" onClick={(e) => { e.preventDefault(); navigate(`/app/chat/${encodeURIComponent(model.id)}`) }} className="badge badge-info" style={{ textDecoration: 'none', cursor: 'pointer' }}>Chat</a>
{(() => {
const caps = Array.isArray(model.capabilities) ? model.capabilities : []
const matched = USE_CASES.filter(uc => caps.includes(uc.cap) && !(uc.hideIf && caps.includes(uc.hideIf)))
if (matched.length === 0) {
return <span className="cell-muted"></span>
}
return matched.map(uc => uc.route ? (
<a
key={uc.cap}
href="#"
onClick={(e) => { e.preventDefault(); navigate(uc.route(model.id)) }}
className="badge badge-info"
style={{ textDecoration: 'none', cursor: 'pointer' }}
>{uc.label}</a>
) : (
<span key={uc.cap} className="badge">{uc.label}</span>
))
})()}
</div>
</td>
{/* Actions */}