From de8e6e42d6dfd62930d19cbeb3a43bce965bd425 Mon Sep 17 00:00:00 2001 From: martinma51 Date: Thu, 21 May 2026 11:48:37 +0800 Subject: [PATCH] feat(models): auto-suggest abilities from curated catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../pages/model-management-dialog.tsx | 160 +++++- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/lib/models.ts | 65 +++ src/xagent/core/model/abilities_catalog.yaml | 373 ++++++++++++++ src/xagent/web/api/model.py | 27 + src/xagent/web/schemas/model.py | 20 +- src/xagent/web/services/model_catalog.py | 250 ++++++++++ tests/web/test_model_catalog.py | 462 ++++++++++++++++++ 9 files changed, 1352 insertions(+), 9 deletions(-) create mode 100644 src/xagent/core/model/abilities_catalog.yaml create mode 100644 src/xagent/web/services/model_catalog.py create mode 100644 tests/web/test_model_catalog.py diff --git a/frontend/src/components/pages/model-management-dialog.tsx b/frontend/src/components/pages/model-management-dialog.tsx index 00e21283e..a3851203b 100644 --- a/frontend/src/components/pages/model-management-dialog.tsx +++ b/frontend/src/components/pages/model-management-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useMemo } from "react" +import { useState, useMemo, useRef } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -13,11 +13,13 @@ import { getApiUrl } from "@/lib/utils" import { useAuth } from "@/contexts/auth-context" import { apiRequest } from "@/lib/api-wrapper" import { + AbilitySuggestion, DefaultModelType, + getAbilitySuggestion, getProviderModels, ProviderModel, removeUserDefaultModel, - setUserDefaultModel + setUserDefaultModel, } from "@/lib/models" import { ArrowLeft, @@ -92,6 +94,33 @@ export function ModelManagementDialog({ const [hasInitializedDefaults, setHasInitializedDefaults] = useState(false) const [selectedDefaultConfigTypes, setSelectedDefaultConfigTypes] = useState([]) + // Ability auto-fill from curated catalog. + // - userTouchedAbilities: once the user clicks any ability button we stop + // overwriting their selection. This survives switching to a different + // model_name within the same Add-Model session, by design. + // Initialised to true when the dialog opens in edit mode so we never + // silently overwrite abilities the user previously chose. + // - abilitySuggestion: the latest catalog response, used to render a hint. + // - suggestionRequestCounter: monotonic counter used to discard responses + // from stale in-flight requests when the user types quickly (e.g. in + // the edit-mode Input where every keystroke triggers a lookup). Without + // this, an older request resolving after a newer one would overwrite + // the UI with a stale suggestion. + const [userTouchedAbilities, setUserTouchedAbilities] = useState(!!initialEditingModel) + const [abilitySuggestion, setAbilitySuggestion] = useState< + Pick | null + >(null) + const suggestionRequestCounter = useRef(0) + // Mirror userTouchedAbilities in a ref so async callbacks see the latest + // value rather than the closure-captured render-time value. Required so + // an in-flight applyAbilitySuggestion can't overwrite an ability edit the + // user makes while the request is still in flight. + const userTouchedAbilitiesRef = useRef(!!initialEditingModel) + const setUserTouched = (value: boolean) => { + userTouchedAbilitiesRef.current = value + setUserTouchedAbilities(value) + } + const getDefaultAbilitiesForCategory = (category: string): string[] => { if (category === 'llm') return ['chat'] if (category === 'embedding') return ['embedding'] @@ -129,6 +158,69 @@ export function ModelManagementDialog({ ] } + /** + * Reset all "fresh wizard run" ability auto-fill state. Used wherever + * the user starts a new model_name selection from scratch (entering the + * wizard, switching category, switching provider). + * + * Bumps suggestionRequestCounter so that any applyAbilitySuggestion() + * already in flight from the previous selection has its response + * discarded — otherwise a stale fetch could resolve after the reset + * and auto-fill the form with the previous model's abilities. + */ + const resetAbilitySuggestionState = () => { + suggestionRequestCounter.current++ + setUserTouched(false) + setAbilitySuggestion(null) + } + + /** + * Look up abilities for the chosen (provider, model_name) against the + * backend catalog and, if the user hasn't manually touched the ability + * buttons yet, apply the suggestion. Always updates `abilitySuggestion` + * so the hint stays in sync, even when we don't auto-fill. + * + * Only applies for category='llm' — other categories have a fixed, + * single-ability shape that doesn't benefit from a catalog lookup. + * + * Race-condition safety: every call bumps a monotonic counter, and the + * response is only applied if the counter is still the latest when we + * resolve. This prevents an older keystroke's response from overwriting + * a newer one when network round-trips finish out of order. + */ + const applyAbilitySuggestion = async (provider: string, modelName: string, category: string) => { + const requestId = ++suggestionRequestCounter.current + if (category !== 'llm' || !provider || !modelName) { + setAbilitySuggestion(null) + return + } + const result = await getAbilitySuggestion(provider, modelName) + // A newer request has been fired since — drop this stale response. + if (requestId !== suggestionRequestCounter.current) { + return + } + setAbilitySuggestion({ source: result.source, matched_pattern: result.matched_pattern }) + // Re-check the ref instead of the closure value: the user may have + // edited abilities while this network request was in flight. + if (result.source !== 'none' && !userTouchedAbilitiesRef.current) { + setFormData(prev => ({ ...prev, abilities: result.abilities })) + } + } + + /** + * Centralised handler for model_name changes. Used by both wizard and + * form-mode Inputs/Selects to avoid duplicating the setFormData + + * applyAbilitySuggestion pair. We read provider/category from the + * functional setState `prev` to avoid stale closure values when the + * user changes them in quick succession. + */ + const handleModelNameChange = (newModelName: string) => { + setFormData(prev => { + void applyAbilitySuggestion(prev.model_provider, newModelName, prev.category) + return { ...prev, model_name: newModelName } + }) + } + const resetConnectionState = () => { setTestConnectionStatus('idle') setTestConnectionError(null) @@ -251,6 +343,13 @@ export function ModelManagementDialog({ setDefaultTargetModel(null) setEditingModel(model) const currentDefaults = getModelDefaultTypes(model.id) + // Editing an existing model: the user has already explicitly chosen + // these abilities (or accepted the catalog defaults at creation time), + // so we must not silently overwrite them when they tweak model_name. + // The hint UI still updates so they can see what the catalog would + // suggest for the new name. + setUserTouched(true) + setAbilitySuggestion(null) setFormData({ model_id: model.model_id, category: model.category, @@ -265,6 +364,8 @@ export function ModelManagementDialog({ share_with_users: model.is_shared }) setViewMode('form') + // Show the hint for the model they're editing, without changing abilities. + void applyAbilitySuggestion(model.model_provider, model.model_name, model.category) } const handleManageDefaults = (model: Model) => { @@ -279,6 +380,8 @@ export function ModelManagementDialog({ const providerConfig = providers.find(p => p.id === managingProviderId) resetConnectionState() setDefaultTargetModel(null) + // Fresh wizard run -> drop any prior auto-fill state. + resetAbilitySuggestionState() setFormData({ model_id: "", category: activeTab, @@ -598,6 +701,7 @@ export function ModelManagementDialog({ value={formData.category} onValueChange={(value) => { resetConnectionState() + resetAbilitySuggestionState() setFormData(prev => ({ ...prev, category: value, @@ -640,6 +744,9 @@ export function ModelManagementDialog({ className={`flex items-center gap-4 p-4 cursor-pointer hover:bg-muted/50 ${formData.model_provider === provider.id ? 'bg-muted' : ''}`} onClick={() => { resetConnectionState() + // Provider change implies the prior model_name is gone, so any + // earlier suggestion no longer applies. Allow auto-fill again. + resetAbilitySuggestionState() setFormData(prev => ({ ...prev, model_provider: provider.id, @@ -763,7 +870,7 @@ export function ModelManagementDialog({ { + // Upstream already clears model_name + resets abilities + // to the new provider's defaults here, which by itself + // prevents the stale-suggestion bug rogercloud/qinxuye + // flagged. We still reset the suggestion hint state + // explicitly so the "Auto-filled from " badge + // doesn't linger after the provider change. resetConnectionState() + resetAbilitySuggestionState() setFormData(prev => ({ ...prev, model_provider: value, @@ -1234,13 +1366,19 @@ export function ModelManagementDialog({ setFormData({ ...formData, model_name: e.target.value })} + onChange={(e) => { + // Refresh the hint; userTouchedAbilities=true keeps + // the actual ability buttons intact (set in handleEdit). + handleModelNameChange(e.target.value) + }} placeholder={t('models.form.enterModelName')} /> ) : (