11"use client"
22
3- import { useState , useMemo } from "react"
3+ import { useState , useMemo , useRef } from "react"
44import { Button } from "@/components/ui/button"
55import { Input } from "@/components/ui/input"
66import { Label } from "@/components/ui/label"
@@ -13,11 +13,13 @@ import { getApiUrl } from "@/lib/utils"
1313import { useAuth } from "@/contexts/auth-context"
1414import { apiRequest } from "@/lib/api-wrapper"
1515import {
16+ AbilitySuggestion ,
1617 DefaultModelType ,
18+ getAbilitySuggestion ,
1719 getProviderModels ,
1820 ProviderModel ,
1921 removeUserDefaultModel ,
20- setUserDefaultModel
22+ setUserDefaultModel ,
2123} from "@/lib/models"
2224import {
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 :
0 commit comments