Skip to content
Merged
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
146 changes: 133 additions & 13 deletions src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AIModelConfigType> {
const map = new Map<string, AIModelConfigType>();
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, AIModelConfigType>
): 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<string, AIModelConfigType>,
singleSelection: boolean
): string[] {
let list = uniqModelNames(modelNames);
if (singleSelection) {
list = list.slice(0, 1);
}
const seen = new Set<string>();
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':
Expand Down Expand Up @@ -294,30 +362,44 @@ const AIModelConfig: React.FC = () => {
baseConfig?: Partial<AIModelConfigType>,
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,
});
})
);
Expand Down Expand Up @@ -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>): AIModelConfigType | null => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/locales/en-US/settings/ai-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/ai-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"inputModelName": "输入自定义模型名称...",
"useCustomModel": "按 Enter 使用",
"addCustomModel": "添加自定义模型",
"modelAlreadyInList": "该模型已在列表中",
"reusedExistingModel": "已使用当前服务商下已有同名模型配置(未新增重复项)",
"noModelsSelected": "请先选择模型",
"removeModel": "移除模型",
"urlHint": "已预填官方 API 地址,如使用代理或私有部署可修改",
Expand Down Expand Up @@ -237,6 +239,7 @@
"messages": {
"fillRequired": "请填写必要字段",
"fillModelName": "请填写模型名称",
"duplicateModelNameUnderProvider": "该服务商下已有同名模型,请改用其他名称或编辑已有条目",
"saveFailed": "保存失败",
"deleteFailed": "删除配置失败",
"loadFailed": "加载AI配置失败",
Expand Down
Loading