-
diff --git a/core/http/react-ui/src/components/swarm/ModelPicker.jsx b/core/http/react-ui/src/components/swarm/ModelPicker.jsx
deleted file mode 100644
index be6cabd929..0000000000
--- a/core/http/react-ui/src/components/swarm/ModelPicker.jsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useEffect, useId, useMemo, useRef, useState } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useModels } from '../../hooks/useModels'
-
-const LIMIT = 50
-
-// A model name, typed or picked. A rule may be written before its model is
-// installed, so what is typed is kept as it is: the list only helps. Aliases
-// are offered beside models, each with the model it resolves to.
-//
-// hints { [name]: 'alias of x' } shown under a name and searched with it
-export default function ModelPicker({ id, value, onChange, hints = {} }) {
- const { t } = useTranslation('swarm')
- const { models } = useModels()
- const listId = useId()
- const [open, setOpen] = useState(false)
- const [active, setActive] = useState(-1)
- const wrapRef = useRef(null)
-
- useEffect(() => {
- const away = event => { if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false) }
- document.addEventListener('mousedown', away)
- return () => document.removeEventListener('mousedown', away)
- }, [])
-
- const needle = (value || '').toLowerCase()
- const matches = useMemo(() => {
- const names = [...new Set([...models.map(m => m.id), ...Object.keys(hints)])]
- return names.filter(name => name.toLowerCase().includes(needle) || (hints[name] || '').toLowerCase().includes(needle)).slice(0, LIMIT)
- }, [models, hints, needle])
-
- const pick = name => { onChange(name); setOpen(false); setActive(-1) }
-
- const onKeyDown = event => {
- if (event.key === 'ArrowDown') {
- event.preventDefault()
- if (!open) { setOpen(true); return }
- setActive(i => Math.min(i + 1, matches.length - 1))
- } else if (event.key === 'ArrowUp') {
- event.preventDefault()
- setActive(i => Math.max(i - 1, 0))
- } else if (event.key === 'Enter' && open) {
- event.preventDefault()
- pick(active >= 0 ? matches[active] : (matches.length > 0 && !matches.includes(value) ? matches[0] : value))
- } else if (event.key === 'Escape' && open) {
- event.preventDefault()
- setOpen(false)
- setActive(-1)
- }
- }
-
- return (
-
-
= 0 ? `${listId}-${active}` : undefined}
- autoComplete="off"
- spellCheck={false}
- placeholder={t('sheet.modelPlaceholder')}
- value={value}
- onChange={event => { onChange(event.target.value); setOpen(true); setActive(-1) }}
- onFocus={() => setOpen(true)}
- onKeyDown={onKeyDown}
- />
- {open && (
-
- {matches.map((name, index) => (
-
{ event.preventDefault(); pick(name) }}
- onMouseEnter={() => setActive(index)}
- >
-
- {name}
- {hints[name] && {hints[name]}}
-
-
- ))}
- {matches.length === 0 &&
{t('sheet.pickerEmpty', { name: value })}
}
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/components/swarm/RuleSheet.jsx b/core/http/react-ui/src/components/swarm/RuleSheet.jsx
index 5bc51c2e18..b4e6a73cf5 100644
--- a/core/http/react-ui/src/components/swarm/RuleSheet.jsx
+++ b/core/http/react-ui/src/components/swarm/RuleSheet.jsx
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
-import ModelPicker from './ModelPicker'
+import ModelPicker from '../ModelPicker'
import KeyValueChips from '../nodes/KeyValueChips'
import { rulePlan, selectorOf } from '../../utils/swarm'
import Icon from '../Icon'
@@ -110,7 +110,6 @@ export default function RuleSheet({ initial, editing, labels, aliases, nodes, on
}, [])
const hasSelector = Object.keys(selector).length > 0
- const aliasHints = Object.fromEntries(Object.entries(aliases || {}).map(([name, target]) => [name, t('sheet.aliasOf', { target })]))
const aliasTarget = (aliases || {})[modelName]
const valid = () => {
@@ -165,7 +164,7 @@ export default function RuleSheet({ initial, editing, labels, aliases, nodes, on
{editing ? (
) : (
-
+
)}
{/* An alias is a stable name for whichever model serves it, so a rule
on one is a rule on a slot. Say so where it is chosen. */}
diff --git a/core/http/react-ui/src/hooks/useModels.js b/core/http/react-ui/src/hooks/useModels.js
index 3cb4c74c0c..492f6487e4 100644
--- a/core/http/react-ui/src/hooks/useModels.js
+++ b/core/http/react-ui/src/hooks/useModels.js
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import { modelsApi } from '../utils/api'
// `enabled: false` skips the request, for a caller that already holds the list.
-export function useModels(capability, { enabled = true } = {}) {
+// `keepBroken: true` keeps an alias that cannot be resolved (it has no
+// capabilities) in a filtered list, for a picker that shows it as broken.
+export function useModels(capability, { enabled = true, keepBroken = false } = {}) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -14,6 +16,7 @@ export function useModels(capability, { enabled = true } = {}) {
let items = (data?.data || []).filter(m => !capability || !m.disabled)
if (capability) {
items = items.filter(m =>
+ (keepBroken && m.alias_error) ||
m.capabilities?.includes(capability) || m.capabilities?.includes(capability.replace(/^FLAG_/, '').toLowerCase()) ||
// Models without config (loose files) have no capabilities — show them only when no filter
false
@@ -33,7 +36,7 @@ export function useModels(capability, { enabled = true } = {}) {
} finally {
if (!silent) setLoading(false)
}
- }, [capability])
+ }, [capability, keepBroken])
// Subsequent refetches stay silent so consumers don't blank their tables
// (e.g. the Manage page auto-refreshes every 10s in distributed mode).
diff --git a/core/http/react-ui/src/pages/AgentTaskDetails.jsx b/core/http/react-ui/src/pages/AgentTaskDetails.jsx
index 09c91ad17e..30be94351a 100644
--- a/core/http/react-ui/src/pages/AgentTaskDetails.jsx
+++ b/core/http/react-ui/src/pages/AgentTaskDetails.jsx
@@ -4,7 +4,7 @@ import { Link, useParams, useNavigate, useOutletContext, useLocation } from 'rea
import { useTranslation } from 'react-i18next'
import { chatAgentsApi, chatTriggersApi, filesApi } from '../utils/api'
// eslint-disable-next-line no-unused-vars
-import ModelSelector from '../components/ModelSelector'
+import ModelPicker from '../components/ModelPicker'
// eslint-disable-next-line no-unused-vars
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
// eslint-disable-next-line no-unused-vars
@@ -514,7 +514,7 @@ function TaskForm({ id, isNew }) {
{!picked?.model && (
{t('jobs.edit.model')}
- set('model', model)} capability={CAP_CHAT} />
+ set('model', model)} capability={CAP_CHAT} />
)}
diff --git a/core/http/react-ui/src/pages/swarm.css b/core/http/react-ui/src/pages/swarm.css
index 8d55975521..f0125f1e31 100644
--- a/core/http/react-ui/src/pages/swarm.css
+++ b/core/http/react-ui/src/pages/swarm.css
@@ -288,9 +288,6 @@
/* ---- Rule sheet ---------------------------------------------------------------- */
-.sw-picker { position: relative; }
-.sw-picker .dk-input { width: 100%; }
-.sw-picker__list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: var(--dk-z-popover); max-height: 240px; overflow-y: auto; }
.sw-sheet__body { display: flex; flex-direction: column; gap: var(--dk-space-4); }
.sw-two { display: grid; grid-template-columns: 1fr 1fr; gap: var(--dk-space-3); }