Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 152 additions & 8 deletions frontend/src/components/pages/model-management-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -92,6 +94,33 @@ export function ModelManagementDialog({
const [hasInitializedDefaults, setHasInitializedDefaults] = useState(false)
const [selectedDefaultConfigTypes, setSelectedDefaultConfigTypes] = useState<string[]>([])

// 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
Comment thread
martinma51 marked this conversation as resolved.
// 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<AbilitySuggestion, "source" | "matched_pattern"> | 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<boolean>(!!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']
Expand Down Expand Up @@ -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 = () => {
Comment thread
qinxuye marked this conversation as resolved.
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 }))
}
}
Comment thread
qinxuye marked this conversation as resolved.

/**
* 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recompute suggestions when the form provider changes

This helper keeps suggestions in sync when model_name changes, but the direct form's provider selector still only updates model_provider. If a user selects OpenAI/gpt-4o and gets auto-filled abilities, then switches the provider to another LLM provider while keeping the same model name, the old OpenAI hint and abilities remain in the form. Please add the same kind of provider-change path, either resetting the suggestion/model abilities or re-running applyAbilitySuggestion(newProvider, prev.model_name, prev.category) so the submitted abilities match the current provider/model pair.

return { ...prev, model_name: newModelName }
})
}

const resetConnectionState = () => {
setTestConnectionStatus('idle')
setTestConnectionError(null)
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -598,6 +701,7 @@ export function ModelManagementDialog({
value={formData.category}
onValueChange={(value) => {
resetConnectionState()
resetAbilitySuggestionState()
setFormData(prev => ({
...prev,
category: value,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -763,7 +870,7 @@ export function ModelManagementDialog({
<Select
value={formData.model_name}
onValueChange={(val) => {
setFormData({ ...formData, model_name: val })
handleModelNameChange(val)
setTestConnectionStatus('idle')
setTestConnectionError(null)
}}
Expand All @@ -777,7 +884,7 @@ export function ModelManagementDialog({
? prev
: [...prev, { id: val, object: "model", created: Date.now(), owned_by: formData.model_provider }]
)
setFormData({ ...formData, model_name: val })
handleModelNameChange(val)
setTestConnectionStatus('idle')
setTestConnectionError(null)
}}
Expand Down Expand Up @@ -814,6 +921,22 @@ export function ModelManagementDialog({

<div className="space-y-2">
<Label className="text-base font-medium">{t('models.form.abilities')}</Label>
{formData.category === 'llm' && formData.model_name && abilitySuggestion && (
abilitySuggestion.source !== 'none' ? (
<p className="text-xs text-muted-foreground">
{t('models.form.abilitiesAutoFilled', {
pattern: abilitySuggestion.matched_pattern || '',
defaultValue: 'Pre-selected based on a known model ({{pattern}}). Adjust if needed.'
})}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('models.form.abilitiesUnknownModel', {
defaultValue: "We don't have ability info for this model — please pick what it supports."
})}
</p>
)
)}
<div className="flex gap-2 flex-wrap">
{getAbilityOptionsForCategory(formData.category).map(({ value, label }) => {
const cap = value
Expand All @@ -837,6 +960,8 @@ export function ModelManagementDialog({
onClick={() => {
const abilities = formData.abilities || []
resetConnectionState()
// From this point on, never overwrite user choices from the catalog.
setUserTouched(true)
if (isSelected) setFormData({ ...formData, abilities: abilities.filter(a => a !== cap) })
else setFormData({ ...formData, abilities: [...abilities, cap] })
}}
Expand Down Expand Up @@ -1155,7 +1280,14 @@ export function ModelManagementDialog({
<Select
value={formData.model_provider}
onValueChange={(value) => {
// 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 <pattern>" badge
// doesn't linger after the provider change.
resetConnectionState()
resetAbilitySuggestionState()
setFormData(prev => ({
...prev,
model_provider: value,
Expand Down Expand Up @@ -1234,13 +1366,19 @@ export function ModelManagementDialog({
<Input
id="model_name"
value={formData.model_name}
onChange={(e) => 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In edit/form mode this now calls handleModelNameChange(), which fetches and stores an abilitySuggestion, but the known/unknown suggestion hint is only rendered in the connect wizard above. Because handleEdit() sets userTouchedAbilities=true, the suggestion also won't auto-apply, so edit-mode users pay for a lookup whose result is invisible. Please either render the same hint above this form-mode abilities MultiSelect, or skip the lookup in this path.

}}
Comment thread
qinxuye marked this conversation as resolved.
placeholder={t('models.form.enterModelName')}
/>
) : (
<Select
value={formData.model_name}
onValueChange={(value) => setFormData({ ...formData, model_name: value })}
onValueChange={(value) => {
handleModelNameChange(value)
}}
options={fetchedModels.map(m => ({ value: m.id, label: m.id }))}
placeholder={t('models.form.selectModel')}
allowCustom={formData.model_provider !== 'deepseek'}
Expand All @@ -1250,7 +1388,7 @@ export function ModelManagementDialog({
if (!fetchedModels.find(m => m.id === value)) {
Comment thread
qinxuye marked this conversation as resolved.
setFetchedModels([...fetchedModels, { id: value, object: "model", created: Date.now(), owned_by: formData.model_provider }])
}
setFormData({ ...formData, model_name: value })
handleModelNameChange(value)
}}
/>
)}
Expand All @@ -1260,7 +1398,13 @@ export function ModelManagementDialog({
<Label className="mb-2 block">{t('models.form.abilities')}</Label>
<MultiSelect
values={formData.abilities || []}
onValuesChange={(values) => setFormData({ ...formData, abilities: values })}
onValuesChange={(values) => {
// Mark abilities as user-touched so a later model_name
// change in form mode doesn't auto-fill over the user's
// explicit selection. Matches the wizard ability button.
setUserTouched(true)
setFormData({ ...formData, abilities: values })
}}
options={
formData.category === 'llm' ? abilityOptions :
formData.category === 'embedding' ? embeddingAbilityOptions :
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,8 @@ Build when you need.`
defaultPlaceholder: "Select default type...",
shareWithUsers: "Share this model with all users",
abilitiesPlaceholder: "Select abilities...",
abilitiesAutoFilled: "Pre-selected based on a known model ({{pattern}}). Adjust if needed.",
abilitiesUnknownModel: "We don't have ability info for this model — please pick what it supports.",
update: "Update Model",
create: "Create Model",
enterModelName: "Enter model name manually...",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,8 @@ Build when you need.`
defaultPlaceholder: "选择默认类型...",
shareWithUsers: "与所有用户共享此模型",
abilitiesPlaceholder: "选择能力...",
abilitiesAutoFilled: "已根据已知模型自动选择({{pattern}}),如有偏差请手动修改。",
abilitiesUnknownModel: "暂无该模型的能力信息,请按实际支持手动选择。",
update: "更新模型",
create: "创建模型",
enterModelName: "手动输入模型名称...",
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,68 @@ export async function getProviderModels(
}
return Array.isArray(data) ? data : [];
}

/**
* Result of looking up abilities for a (provider, model_name) pair against
* the curated ability catalog on the backend.
*
* `source` semantics:
* - "exact": matched a provider-specific rule
* - "wildcard_provider": matched a cross-provider rule (e.g. DeepSeek
* served via an OpenAI-compatible endpoint)
* - "none": no rule matched; abilities array will be empty
*/
export interface AbilitySuggestion {
abilities: string[]
matched_pattern: string | null
source: "exact" | "wildcard_provider" | "none"
}

/**
* Ask the backend which abilities to pre-select for a given model.
* Returns `{ abilities: [], source: "none" }` for unknown models so callers
* can simply test `source !== "none"` to decide whether to auto-fill.
*
* Network failures are swallowed and surfaced as `source: "none"` — the
* Add-Model wizard should keep working even if the catalog endpoint is down.
*/
export async function getAbilitySuggestion(
provider: string,
modelName: string,
): Promise<AbilitySuggestion> {
if (!provider || !modelName) {
return { abilities: [], matched_pattern: null, source: "none" }
}
const apiUrl = getApiUrl()
const qs = new URLSearchParams({ provider, model_name: modelName }).toString()
try {
const response = await apiRequest(`${apiUrl}/api/models/abilities/suggest?${qs}`, {
method: "GET",
})
if (!response.ok) {
return { abilities: [], matched_pattern: null, source: "none" }
}
const data = (await response.json()) as Partial<AbilitySuggestion>
// Whitelist-validate `source` instead of casting: an unexpected value
// from the backend would otherwise leak through the cast into React
// state and break downstream union-narrowing.
const validSources: AbilitySuggestion["source"][] = [
"exact",
"wildcard_provider",
"none",
]
const source: AbilitySuggestion["source"] = validSources.includes(
data.source as AbilitySuggestion["source"],
)
? (data.source as AbilitySuggestion["source"])
: "none"
return {
abilities: Array.isArray(data.abilities) ? data.abilities : [],
matched_pattern: data.matched_pattern ?? null,
source,
}
Comment thread
martinma51 marked this conversation as resolved.
} catch (error) {
console.error("Failed to get ability suggestion:", error)
return { abilities: [], matched_pattern: null, source: "none" }
}
}
Loading
Loading