fix(agents): keep each (provider, model) unique in the preference chain

In the "Edit Agent → Preferred Providers" tab the same (provider, model)
combination could be selected repeatedly — e.g. two rows of the same
provider both pointing at the same model, or two "provider default" rows.
This is unintended: a provider may repeat across the fallback chain, but
each (provider, model) should stay unique.

Root cause: addProviderEntry() pushed unconditionally (the code comment
even said "we never dedup here") and the model <option>s had no disabled
state, so already-chosen models remained selectable.

Fix (Agents.vue):
- isProviderChoiceTaken(): detect whether a (provider, model) slot — or the
  provider-default slot (modelId === null) — is already used in another row.
- Model <option> + the default-model option are :disabled when already taken;
  the current row's own value stays selectable (exceptIdx).
- addProviderEntry(): take the default slot if free, else the first unused
  model; do nothing if every option is taken.
- The "+ Provider" pool button is disabled once the provider has no free
  (provider, model) slot left, so the click is never a silent no-op.

The existing unique index uk_agent_provider_model(agent_id, provider_id,
model_id) already guards non-null duplicates at the DB level, but it cannot
catch model_id IS NULL rows (SQL treats NULLs as distinct); the UI is now
the single source of truth for that.

Fixes #530
This commit is contained in:
倪程伟 2026-07-15 14:56:16 +08:00 committed by GitHub
parent 97a040aa89
commit fbfb8982cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -606,8 +606,15 @@
<span class="provider-pref-rank">{{ idx + 1 }}</span>
<span class="provider-pref-name">{{ providerNameById(pref.providerId) }}</span>
<select class="provider-pref-model" v-model="pref.modelId">
<option :value="null">{{ t('agents.binding.providerDefaultModel') }}</option>
<option v-for="m in modelsForProvider(pref.providerId)" :key="m.id" :value="m.id">
<option :value="null" :disabled="isProviderChoiceTaken(pref.providerId, null, idx)">
{{ t('agents.binding.providerDefaultModel') }}
</option>
<option
v-for="m in modelsForProvider(pref.providerId)"
:key="m.id"
:value="m.id"
:disabled="isProviderChoiceTaken(pref.providerId, m.id, idx)"
>
{{ m.modelName }}
</option>
</select>
@ -625,6 +632,7 @@
v-for="p in availableProviders"
:key="p.id"
class="provider-pref-add-btn"
:disabled="!hasFreeProviderChoice(p.id)"
@click="addProviderEntry(p.id)"
>+ {{ p.name }}</button>
</div>
@ -1251,10 +1259,38 @@ function modelsForProvider(providerId: string) {
return availableModels.value.filter(m => m.provider === providerId)
}
// Append a new chain entry (defaults to the provider's default model). The same
// provider may appear more than once, so we never dedup here.
// Whether a specific (provider, model) or the provider's default slot when
// modelId === null is already chosen in another row. Keeps each
// (provider, model) unique across the chain. `exceptIdx` excludes one row
// (pass the current row's index so its own value stays selectable); pass -1
// to check against every row (used when appending).
function isProviderChoiceTaken(providerId: string, modelId: string | null, exceptIdx: number): boolean {
return selectedProviderPrefs.value.some(
(pref, i) => i !== exceptIdx && pref.providerId === providerId && pref.modelId === modelId,
)
}
// Whether the provider still has any free (provider, model) slot to add.
function hasFreeProviderChoice(providerId: string): boolean {
if (!isProviderChoiceTaken(providerId, null, -1)) return true
return modelsForProvider(providerId).some(m => !isProviderChoiceTaken(providerId, m.id, -1))
}
// Append a new chain entry. The same provider may repeat across the chain,
// but each (provider, model) must stay unique: take the default slot if it is
// still free, otherwise the first unused model. If every option is already
// picked there is nothing to add.
function addProviderEntry(providerId: string) {
selectedProviderPrefs.value.push({ providerId, modelId: null })
if (!isProviderChoiceTaken(providerId, null, -1)) {
selectedProviderPrefs.value.push({ providerId, modelId: null })
return
}
const firstFree = modelsForProvider(providerId).find(
m => !isProviderChoiceTaken(providerId, m.id, -1),
)
if (firstFree) {
selectedProviderPrefs.value.push({ providerId, modelId: firstFree.id })
}
}
function removeProviderEntry(idx: number) {