Skip to content

Commit 51b31d5

Browse files
author
martinma51
committed
feat(models): auto-suggest abilities from curated catalog
Add a backend YAML catalog (``src/xagent/core/model/abilities_catalog.yaml``) mapping (provider, model_name_pattern) to a curated set of abilities, plus a backend lookup service and a ``GET /api/models/ability_suggestion`` endpoint. On the frontend, the model-management dialog now auto-fills ability checkboxes from the catalog as the user picks/types a model, with a hint chip showing the matched rule. Backend * ``model_catalog`` service with three-tier matching: exact provider+model, wildcard-provider, then provider-default fallback * YAML covers OpenAI GPT-4o/4.1/5/5.5, Claude 3.5/4/4.5/5, Gemini 2/3, DeepSeek V3/R1/R2/V4, Qwen 2.5/3, plus cross-provider rules for \"-vl\"/\"-4v\" coding-plan model names that imply vision support * Lazy load with double-checked locking; structured fall-throughs so unknown providers degrade gracefully to category defaults Frontend * ``model-management-dialog.tsx`` calls the suggestion endpoint as the user picks (wizard) or types (form mode) a model name and as they switch provider; the response auto-fills abilities only when the user hasn't manually touched them since this wizard run * Stale-response guard via monotonic request counter so out-of-order network responses can't overwrite a newer one * Manual ability edits are tracked via a ref (not closure value) so in-flight responses can't clobber them * Provider/category resets invalidate any in-flight lookup before clearing state to prevent old-model auto-fill bleeding into the reset form Review feedback addressed (rogercloud APPROVED 5/9, qinxuye 5/13): * P2 \"recompute suggestions when form provider changes\" — the form provider Select now calls ``resetAbilitySuggestionState()`` and the upstream-merged ``model_name=\"\"`` reset together; subsequent model_name input retriggers a fresh catalog lookup * Frontend state race conditions (stale closure of ``userTouchedAbilities``, in-flight requests surviving state resets) fixed via refs + counter
1 parent 400dda6 commit 51b31d5

9 files changed

Lines changed: 1352 additions & 9 deletions

File tree

frontend/src/components/pages/model-management-dialog.tsx

Lines changed: 152 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import { useState, useMemo } from "react"
3+
import { useState, useMemo, useRef } from "react"
44
import { Button } from "@/components/ui/button"
55
import { Input } from "@/components/ui/input"
66
import { Label } from "@/components/ui/label"
@@ -13,11 +13,13 @@ import { getApiUrl } from "@/lib/utils"
1313
import { useAuth } from "@/contexts/auth-context"
1414
import { apiRequest } from "@/lib/api-wrapper"
1515
import {
16+
AbilitySuggestion,
1617
DefaultModelType,
18+
getAbilitySuggestion,
1719
getProviderModels,
1820
ProviderModel,
1921
removeUserDefaultModel,
20-
setUserDefaultModel
22+
setUserDefaultModel,
2123
} from "@/lib/models"
2224
import {
2325
ArrowLeft,
@@ -92,6 +94,33 @@ export function ModelManagementDialog({
9294
const [hasInitializedDefaults, setHasInitializedDefaults] = useState(false)
9395
const [selectedDefaultConfigTypes, setSelectedDefaultConfigTypes] = useState<string[]>([])
9496

97+
// Ability auto-fill from curated catalog.
98+
// - userTouchedAbilities: once the user clicks any ability button we stop
99+
// overwriting their selection. This survives switching to a different
100+
// model_name within the same Add-Model session, by design.
101+
// Initialised to true when the dialog opens in edit mode so we never
102+
// silently overwrite abilities the user previously chose.
103+
// - abilitySuggestion: the latest catalog response, used to render a hint.
104+
// - suggestionRequestCounter: monotonic counter used to discard responses
105+
// from stale in-flight requests when the user types quickly (e.g. in
106+
// the edit-mode Input where every keystroke triggers a lookup). Without
107+
// this, an older request resolving after a newer one would overwrite
108+
// the UI with a stale suggestion.
109+
const [userTouchedAbilities, setUserTouchedAbilities] = useState(!!initialEditingModel)
110+
const [abilitySuggestion, setAbilitySuggestion] = useState<
111+
Pick<AbilitySuggestion, "source" | "matched_pattern"> | null
112+
>(null)
113+
const suggestionRequestCounter = useRef(0)
114+
// Mirror userTouchedAbilities in a ref so async callbacks see the latest
115+
// value rather than the closure-captured render-time value. Required so
116+
// an in-flight applyAbilitySuggestion can't overwrite an ability edit the
117+
// user makes while the request is still in flight.
118+
const userTouchedAbilitiesRef = useRef<boolean>(!!initialEditingModel)
119+
const setUserTouched = (value: boolean) => {
120+
userTouchedAbilitiesRef.current = value
121+
setUserTouchedAbilities(value)
122+
}
123+
95124
const getDefaultAbilitiesForCategory = (category: string): string[] => {
96125
if (category === 'llm') return ['chat']
97126
if (category === 'embedding') return ['embedding']
@@ -129,6 +158,69 @@ export function ModelManagementDialog({
129158
]
130159
}
131160

161+
/**
162+
* Reset all "fresh wizard run" ability auto-fill state. Used wherever
163+
* the user starts a new model_name selection from scratch (entering the
164+
* wizard, switching category, switching provider).
165+
*
166+
* Bumps suggestionRequestCounter so that any applyAbilitySuggestion()
167+
* already in flight from the previous selection has its response
168+
* discarded — otherwise a stale fetch could resolve after the reset
169+
* and auto-fill the form with the previous model's abilities.
170+
*/
171+
const resetAbilitySuggestionState = () => {
172+
suggestionRequestCounter.current++
173+
setUserTouched(false)
174+
setAbilitySuggestion(null)
175+
}
176+
177+
/**
178+
* Look up abilities for the chosen (provider, model_name) against the
179+
* backend catalog and, if the user hasn't manually touched the ability
180+
* buttons yet, apply the suggestion. Always updates `abilitySuggestion`
181+
* so the hint stays in sync, even when we don't auto-fill.
182+
*
183+
* Only applies for category='llm' — other categories have a fixed,
184+
* single-ability shape that doesn't benefit from a catalog lookup.
185+
*
186+
* Race-condition safety: every call bumps a monotonic counter, and the
187+
* response is only applied if the counter is still the latest when we
188+
* resolve. This prevents an older keystroke's response from overwriting
189+
* a newer one when network round-trips finish out of order.
190+
*/
191+
const applyAbilitySuggestion = async (provider: string, modelName: string, category: string) => {
192+
const requestId = ++suggestionRequestCounter.current
193+
if (category !== 'llm' || !provider || !modelName) {
194+
setAbilitySuggestion(null)
195+
return
196+
}
197+
const result = await getAbilitySuggestion(provider, modelName)
198+
// A newer request has been fired since — drop this stale response.
199+
if (requestId !== suggestionRequestCounter.current) {
200+
return
201+
}
202+
setAbilitySuggestion({ source: result.source, matched_pattern: result.matched_pattern })
203+
// Re-check the ref instead of the closure value: the user may have
204+
// edited abilities while this network request was in flight.
205+
if (result.source !== 'none' && !userTouchedAbilitiesRef.current) {
206+
setFormData(prev => ({ ...prev, abilities: result.abilities }))
207+
}
208+
}
209+
210+
/**
211+
* Centralised handler for model_name changes. Used by both wizard and
212+
* form-mode Inputs/Selects to avoid duplicating the setFormData +
213+
* applyAbilitySuggestion pair. We read provider/category from the
214+
* functional setState `prev` to avoid stale closure values when the
215+
* user changes them in quick succession.
216+
*/
217+
const handleModelNameChange = (newModelName: string) => {
218+
setFormData(prev => {
219+
void applyAbilitySuggestion(prev.model_provider, newModelName, prev.category)
220+
return { ...prev, model_name: newModelName }
221+
})
222+
}
223+
132224
const resetConnectionState = () => {
133225
setTestConnectionStatus('idle')
134226
setTestConnectionError(null)
@@ -251,6 +343,13 @@ export function ModelManagementDialog({
251343
setDefaultTargetModel(null)
252344
setEditingModel(model)
253345
const currentDefaults = getModelDefaultTypes(model.id)
346+
// Editing an existing model: the user has already explicitly chosen
347+
// these abilities (or accepted the catalog defaults at creation time),
348+
// so we must not silently overwrite them when they tweak model_name.
349+
// The hint UI still updates so they can see what the catalog would
350+
// suggest for the new name.
351+
setUserTouched(true)
352+
setAbilitySuggestion(null)
254353
setFormData({
255354
model_id: model.model_id,
256355
category: model.category,
@@ -265,6 +364,8 @@ export function ModelManagementDialog({
265364
share_with_users: model.is_shared
266365
})
267366
setViewMode('form')
367+
// Show the hint for the model they're editing, without changing abilities.
368+
void applyAbilitySuggestion(model.model_provider, model.model_name, model.category)
268369
}
269370

270371
const handleManageDefaults = (model: Model) => {
@@ -279,6 +380,8 @@ export function ModelManagementDialog({
279380
const providerConfig = providers.find(p => p.id === managingProviderId)
280381
resetConnectionState()
281382
setDefaultTargetModel(null)
383+
// Fresh wizard run -> drop any prior auto-fill state.
384+
resetAbilitySuggestionState()
282385
setFormData({
283386
model_id: "",
284387
category: activeTab,
@@ -598,6 +701,7 @@ export function ModelManagementDialog({
598701
value={formData.category}
599702
onValueChange={(value) => {
600703
resetConnectionState()
704+
resetAbilitySuggestionState()
601705
setFormData(prev => ({
602706
...prev,
603707
category: value,
@@ -640,6 +744,9 @@ export function ModelManagementDialog({
640744
className={`flex items-center gap-4 p-4 cursor-pointer hover:bg-muted/50 ${formData.model_provider === provider.id ? 'bg-muted' : ''}`}
641745
onClick={() => {
642746
resetConnectionState()
747+
// Provider change implies the prior model_name is gone, so any
748+
// earlier suggestion no longer applies. Allow auto-fill again.
749+
resetAbilitySuggestionState()
643750
setFormData(prev => ({
644751
...prev,
645752
model_provider: provider.id,
@@ -763,7 +870,7 @@ export function ModelManagementDialog({
763870
<Select
764871
value={formData.model_name}
765872
onValueChange={(val) => {
766-
setFormData({ ...formData, model_name: val })
873+
handleModelNameChange(val)
767874
setTestConnectionStatus('idle')
768875
setTestConnectionError(null)
769876
}}
@@ -777,7 +884,7 @@ export function ModelManagementDialog({
777884
? prev
778885
: [...prev, { id: val, object: "model", created: Date.now(), owned_by: formData.model_provider }]
779886
)
780-
setFormData({ ...formData, model_name: val })
887+
handleModelNameChange(val)
781888
setTestConnectionStatus('idle')
782889
setTestConnectionError(null)
783890
}}
@@ -814,6 +921,22 @@ export function ModelManagementDialog({
814921

815922
<div className="space-y-2">
816923
<Label className="text-base font-medium">{t('models.form.abilities')}</Label>
924+
{formData.category === 'llm' && formData.model_name && abilitySuggestion && (
925+
abilitySuggestion.source !== 'none' ? (
926+
<p className="text-xs text-muted-foreground">
927+
{t('models.form.abilitiesAutoFilled', {
928+
pattern: abilitySuggestion.matched_pattern || '',
929+
defaultValue: 'Pre-selected based on a known model ({{pattern}}). Adjust if needed.'
930+
})}
931+
</p>
932+
) : (
933+
<p className="text-xs text-muted-foreground">
934+
{t('models.form.abilitiesUnknownModel', {
935+
defaultValue: "We don't have ability info for this model — please pick what it supports."
936+
})}
937+
</p>
938+
)
939+
)}
817940
<div className="flex gap-2 flex-wrap">
818941
{getAbilityOptionsForCategory(formData.category).map(({ value, label }) => {
819942
const cap = value
@@ -837,6 +960,8 @@ export function ModelManagementDialog({
837960
onClick={() => {
838961
const abilities = formData.abilities || []
839962
resetConnectionState()
963+
// From this point on, never overwrite user choices from the catalog.
964+
setUserTouched(true)
840965
if (isSelected) setFormData({ ...formData, abilities: abilities.filter(a => a !== cap) })
841966
else setFormData({ ...formData, abilities: [...abilities, cap] })
842967
}}
@@ -1155,7 +1280,14 @@ export function ModelManagementDialog({
11551280
<Select
11561281
value={formData.model_provider}
11571282
onValueChange={(value) => {
1283+
// Upstream already clears model_name + resets abilities
1284+
// to the new provider's defaults here, which by itself
1285+
// prevents the stale-suggestion bug rogercloud/qinxuye
1286+
// flagged. We still reset the suggestion hint state
1287+
// explicitly so the "Auto-filled from <pattern>" badge
1288+
// doesn't linger after the provider change.
11581289
resetConnectionState()
1290+
resetAbilitySuggestionState()
11591291
setFormData(prev => ({
11601292
...prev,
11611293
model_provider: value,
@@ -1234,13 +1366,19 @@ export function ModelManagementDialog({
12341366
<Input
12351367
id="model_name"
12361368
value={formData.model_name}
1237-
onChange={(e) => setFormData({ ...formData, model_name: e.target.value })}
1369+
onChange={(e) => {
1370+
// Refresh the hint; userTouchedAbilities=true keeps
1371+
// the actual ability buttons intact (set in handleEdit).
1372+
handleModelNameChange(e.target.value)
1373+
}}
12381374
placeholder={t('models.form.enterModelName')}
12391375
/>
12401376
) : (
12411377
<Select
12421378
value={formData.model_name}
1243-
onValueChange={(value) => setFormData({ ...formData, model_name: value })}
1379+
onValueChange={(value) => {
1380+
handleModelNameChange(value)
1381+
}}
12441382
options={fetchedModels.map(m => ({ value: m.id, label: m.id }))}
12451383
placeholder={t('models.form.selectModel')}
12461384
allowCustom={formData.model_provider !== 'deepseek'}
@@ -1250,7 +1388,7 @@ export function ModelManagementDialog({
12501388
if (!fetchedModels.find(m => m.id === value)) {
12511389
setFetchedModels([...fetchedModels, { id: value, object: "model", created: Date.now(), owned_by: formData.model_provider }])
12521390
}
1253-
setFormData({ ...formData, model_name: value })
1391+
handleModelNameChange(value)
12541392
}}
12551393
/>
12561394
)}
@@ -1260,7 +1398,13 @@ export function ModelManagementDialog({
12601398
<Label className="mb-2 block">{t('models.form.abilities')}</Label>
12611399
<MultiSelect
12621400
values={formData.abilities || []}
1263-
onValuesChange={(values) => setFormData({ ...formData, abilities: values })}
1401+
onValuesChange={(values) => {
1402+
// Mark abilities as user-touched so a later model_name
1403+
// change in form mode doesn't auto-fill over the user's
1404+
// explicit selection. Matches the wizard ability button.
1405+
setUserTouched(true)
1406+
setFormData({ ...formData, abilities: values })
1407+
}}
12641408
options={
12651409
formData.category === 'llm' ? abilityOptions :
12661410
formData.category === 'embedding' ? embeddingAbilityOptions :

frontend/src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,8 @@ Build when you need.`
14261426
defaultPlaceholder: "Select default type...",
14271427
shareWithUsers: "Share this model with all users",
14281428
abilitiesPlaceholder: "Select abilities...",
1429+
abilitiesAutoFilled: "Pre-selected based on a known model ({{pattern}}). Adjust if needed.",
1430+
abilitiesUnknownModel: "We don't have ability info for this model — please pick what it supports.",
14291431
update: "Update Model",
14301432
create: "Create Model",
14311433
enterModelName: "Enter model name manually...",

frontend/src/i18n/locales/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,8 @@ Build when you need.`
14261426
defaultPlaceholder: "选择默认类型...",
14271427
shareWithUsers: "与所有用户共享此模型",
14281428
abilitiesPlaceholder: "选择能力...",
1429+
abilitiesAutoFilled: "已根据已知模型自动选择({{pattern}}),如有偏差请手动修改。",
1430+
abilitiesUnknownModel: "暂无该模型的能力信息,请按实际支持手动选择。",
14291431
update: "更新模型",
14301432
create: "创建模型",
14311433
enterModelName: "手动输入模型名称...",

frontend/src/lib/models.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,68 @@ export async function getProviderModels(
235235
}
236236
return Array.isArray(data) ? data : [];
237237
}
238+
239+
/**
240+
* Result of looking up abilities for a (provider, model_name) pair against
241+
* the curated ability catalog on the backend.
242+
*
243+
* `source` semantics:
244+
* - "exact": matched a provider-specific rule
245+
* - "wildcard_provider": matched a cross-provider rule (e.g. DeepSeek
246+
* served via an OpenAI-compatible endpoint)
247+
* - "none": no rule matched; abilities array will be empty
248+
*/
249+
export interface AbilitySuggestion {
250+
abilities: string[]
251+
matched_pattern: string | null
252+
source: "exact" | "wildcard_provider" | "none"
253+
}
254+
255+
/**
256+
* Ask the backend which abilities to pre-select for a given model.
257+
* Returns `{ abilities: [], source: "none" }` for unknown models so callers
258+
* can simply test `source !== "none"` to decide whether to auto-fill.
259+
*
260+
* Network failures are swallowed and surfaced as `source: "none"` — the
261+
* Add-Model wizard should keep working even if the catalog endpoint is down.
262+
*/
263+
export async function getAbilitySuggestion(
264+
provider: string,
265+
modelName: string,
266+
): Promise<AbilitySuggestion> {
267+
if (!provider || !modelName) {
268+
return { abilities: [], matched_pattern: null, source: "none" }
269+
}
270+
const apiUrl = getApiUrl()
271+
const qs = new URLSearchParams({ provider, model_name: modelName }).toString()
272+
try {
273+
const response = await apiRequest(`${apiUrl}/api/models/abilities/suggest?${qs}`, {
274+
method: "GET",
275+
})
276+
if (!response.ok) {
277+
return { abilities: [], matched_pattern: null, source: "none" }
278+
}
279+
const data = (await response.json()) as Partial<AbilitySuggestion>
280+
// Whitelist-validate `source` instead of casting: an unexpected value
281+
// from the backend would otherwise leak through the cast into React
282+
// state and break downstream union-narrowing.
283+
const validSources: AbilitySuggestion["source"][] = [
284+
"exact",
285+
"wildcard_provider",
286+
"none",
287+
]
288+
const source: AbilitySuggestion["source"] = validSources.includes(
289+
data.source as AbilitySuggestion["source"],
290+
)
291+
? (data.source as AbilitySuggestion["source"])
292+
: "none"
293+
return {
294+
abilities: Array.isArray(data.abilities) ? data.abilities : [],
295+
matched_pattern: data.matched_pattern ?? null,
296+
source,
297+
}
298+
} catch (error) {
299+
console.error("Failed to get ability suggestion:", error)
300+
return { abilities: [], matched_pattern: null, source: "none" }
301+
}
302+
}

0 commit comments

Comments
 (0)