diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 5f67bf94..9d45fd29 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -68,6 +68,74 @@ function uniqModelNames(modelNames: string[]): string[] { return Array.from(new Set(modelNames.map(name => name.trim()).filter(Boolean))); } +function modelNameLookupKey(name: string): string { + return name.trim().toLowerCase(); +} + +/** Map lowercased model_name -> config (first wins if duplicates exist in storage). */ +function buildConfiguredModelsByLowerName(models: AIModelConfigType[]): Map { + const map = new Map(); + for (const model of models) { + const key = modelNameLookupKey(model.model_name); + if (!map.has(key)) { + map.set(key, model); + } + } + return map; +} + +function resolveModelNameWithExisting( + rawName: string, + configuredByLower: Map +): string { + const trimmed = rawName.trim(); + if (!trimmed) return trimmed; + const configured = configuredByLower.get(modelNameLookupKey(trimmed)); + return configured?.model_name.trim() ?? trimmed; +} + +/** + * Trim, optionally collapse to single selection, resolve each name against existing provider models + * (case-insensitive), then dedupe so one provider cannot list the same logical model twice. + */ +function normalizeProviderModelNameList( + modelNames: string[], + configuredByLower: Map, + singleSelection: boolean +): string[] { + let list = uniqModelNames(modelNames); + if (singleSelection) { + list = list.slice(0, 1); + } + const seen = new Set(); + const out: string[] = []; + for (const raw of list) { + const resolved = resolveModelNameWithExisting(raw, configuredByLower); + if (!resolved) continue; + const key = modelNameLookupKey(resolved); + if (seen.has(key)) continue; + seen.add(key); + out.push(resolved); + } + return out; +} + +/** Last line of defense: same logical model name once per save; prefer draft tied to an existing config id. */ +function dedupeSelectedModelDraftsByModelName(drafts: SelectedModelDraft[]): SelectedModelDraft[] { + const out: SelectedModelDraft[] = []; + for (const draft of drafts) { + const k = modelNameLookupKey(draft.modelName); + const i = out.findIndex(d => modelNameLookupKey(d.modelName) === k); + if (i < 0) { + out.push(draft); + continue; + } + const prev = out[i]; + out[i] = !prev.configId && draft.configId ? draft : prev; + } + return out; +} + function getCapabilitiesByCategory(category: ModelCategory): ModelCapability[] { switch (category) { case 'general_chat': @@ -294,30 +362,44 @@ const AIModelConfig: React.FC = () => { baseConfig?: Partial, singleSelection = false ) => { - const nextModelNames = singleSelection - ? uniqModelNames(modelNames).slice(0, 1) - : uniqModelNames(modelNames); - const providerName = ( baseConfig?.name || editingConfig?.name || currentTemplate?.name || '' ).trim(); - const configuredModelsByName = new Map( - getConfiguredModelsForProvider(providerName).map(model => [model.model_name, model]) + const configuredByLower = buildConfiguredModelsByLowerName( + getConfiguredModelsForProvider(providerName) + ); + const nextModelNames = normalizeProviderModelNameList( + modelNames, + configuredByLower, + singleSelection ); + const pinnedRowId = + singleSelection && baseConfig?.id ? String(baseConfig.id) : undefined; + setSelectedModelDrafts(prevDrafts => nextModelNames.map(modelName => { - const existingDraft = prevDrafts.find(draft => draft.modelName === modelName); + const lookupKey = modelNameLookupKey(modelName); + const existingDraft = prevDrafts.find( + draft => modelNameLookupKey(draft.modelName) === lookupKey + ); + const configuredModel = configuredByLower.get(lookupKey); + if (existingDraft) { - return existingDraft; + const configId = pinnedRowId ?? configuredModel?.id ?? existingDraft.configId; + return { + ...existingDraft, + modelName, + configId, + key: configId ?? modelName, + }; } - const configuredModel = configuredModelsByName.get(modelName); return createModelDraft(modelName, configuredModel || baseConfig, { - configId: configuredModel?.id, + configId: pinnedRowId ?? configuredModel?.id, }); }) ); @@ -361,15 +443,38 @@ const AIModelConfig: React.FC = () => { const trimmedModelName = manualModelInput.trim(); if (!trimmedModelName) return; + const providerName = ( + editingConfig?.name || + currentTemplate?.name || + '' + ).trim(); + const configuredByLower = buildConfiguredModelsByLowerName( + getConfiguredModelsForProvider(providerName) + ); + const resolvedName = resolveModelNameWithExisting(trimmedModelName, configuredByLower); + const matchesExistingSaved = configuredByLower.has(modelNameLookupKey(trimmedModelName)); + const alreadyInDrafts = selectedModelDrafts.some( + draft => modelNameLookupKey(draft.modelName) === modelNameLookupKey(trimmedModelName) + ); + + if (alreadyInDrafts) { + notification.info(t('providerSelection.modelAlreadyInList')); + setManualModelInput(''); + return; + } + const nextModelNames = editingConfig?.id - ? [trimmedModelName] + ? [resolvedName] : uniqModelNames([ ...selectedModelDrafts.map(draft => draft.modelName), - trimmedModelName, + resolvedName, ]); syncSelectedModelDrafts(nextModelNames, editingConfig || undefined, !!editingConfig?.id); setManualModelInput(''); + if (matchesExistingSaved) { + notification.info(t('providerSelection.reusedExistingModel')); + } }; const buildModelDiscoveryConfig = (config: Partial): AIModelConfigType | null => { @@ -657,7 +762,8 @@ const AIModelConfig: React.FC = () => { .map(model => model.id) .filter((id): id is string => !!id) ); - const configsToSave: AIModelConfigType[] = selectedModelDrafts.map((draft, index) => { + const draftsToSave = dedupeSelectedModelDraftsByModelName(selectedModelDrafts); + const configsToSave: AIModelConfigType[] = draftsToSave.map((draft, index) => { return { id: editingConfig.id || draft.configId || `model_${Date.now()}_${index}`, name: providerName, @@ -688,6 +794,20 @@ const AIModelConfig: React.FC = () => { }; }); + if (editingConfig.id && configsToSave[0]) { + const dupKey = modelNameLookupKey(configsToSave[0].model_name); + const nameConflict = aiModels.some( + m => + m.id !== editingConfig.id && + getProviderDisplayName(m) === providerName && + modelNameLookupKey(m.model_name) === dupKey + ); + if (nameConflict) { + notification.warning(t('messages.duplicateModelNameUnderProvider')); + return; + } + } + let updatedModels: AIModelConfigType[]; if (editingConfig.id) { updatedModels = aiModels.map(m => m.id === editingConfig.id ? configsToSave[0] : m); diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 441318db..6e696d99 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -32,6 +32,8 @@ "inputModelName": "Enter model name...", "useCustomModel": "Press Enter to use", "addCustomModel": "Add Custom Model", + "modelAlreadyInList": "This model is already in the list", + "reusedExistingModel": "Using the existing model entry for this provider (no duplicate was added)", "noModelsSelected": "Select a model first", "removeModel": "Remove model", "urlHint": "Official API URL pre-filled, modify if using proxy or private deployment", @@ -237,6 +239,7 @@ "messages": { "fillRequired": "Please fill in required fields", "fillModelName": "Please fill in model name", + "duplicateModelNameUnderProvider": "This provider already has a model with this name. Use a different name or edit the existing entry.", "saveFailed": "Save failed", "deleteFailed": "Failed to delete configuration", "loadFailed": "Failed to load AI configuration", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index bd1fb3d3..1f677de2 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -32,6 +32,8 @@ "inputModelName": "输入自定义模型名称...", "useCustomModel": "按 Enter 使用", "addCustomModel": "添加自定义模型", + "modelAlreadyInList": "该模型已在列表中", + "reusedExistingModel": "已使用当前服务商下已有同名模型配置(未新增重复项)", "noModelsSelected": "请先选择模型", "removeModel": "移除模型", "urlHint": "已预填官方 API 地址,如使用代理或私有部署可修改", @@ -237,6 +239,7 @@ "messages": { "fillRequired": "请填写必要字段", "fillModelName": "请填写模型名称", + "duplicateModelNameUnderProvider": "该服务商下已有同名模型,请改用其他名称或编辑已有条目", "saveFailed": "保存失败", "deleteFailed": "删除配置失败", "loadFailed": "加载AI配置失败",