fix(ai): preserve saved and manual models

This commit is contained in:
Abeautifulsnow 2026-08-02 16:33:46 +08:00 committed by GitHub
parent 2220eed285
commit b053adf175
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 100 additions and 7 deletions

View File

@ -57,6 +57,7 @@ import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, resolveAiDatabaseTarget, resolveAiNamespaceSelection, resolveDefaultAiSchema, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai";
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { addConfiguredAiModel, aiModelOptions } from "@/lib/ai/aiConfigList";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
@ -345,7 +346,9 @@ const activeFullConfig = computed(() => {
});
function getModelsForConfig(configId: string) {
return modelCatalogs.get(configId)?.models ?? [];
const config = settings.aiConfigs.find((item) => item.id === configId);
if (!config) return [];
return aiModelOptions(config, modelCatalogs.get(configId)?.models ?? []);
}
function configMatchesModelQuery(config: AiConfigItem, query: string): boolean {
@ -420,12 +423,21 @@ function startManualModel(configId: string) {
nextTick(() => document.querySelector<HTMLInputElement>("[data-manual-model-input]")?.focus());
}
function applyManualModel(configId: string) {
async function applyManualModel(configId: string) {
const modelId = manualModelId.value.trim();
if (!modelId) return;
handleModelSelect(configId, modelId);
manualModelConfigId.value = "";
manualModelId.value = "";
const config = settings.aiConfigs.find((item) => item.id === configId);
if (!config) return;
try {
if (config.model.trim() !== modelId) {
await settings.updateAiConfigItem(configId, { models: addConfiguredAiModel(config.models, modelId) });
}
handleModelSelect(configId, modelId);
manualModelConfigId.value = "";
manualModelId.value = "";
} catch (error) {
toast(translateBackendError(t, error));
}
}
const activeEffortEntry = computed(() => {

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { generateId } from "@/lib/ai/aiConfigList";
import { addConfiguredAiModel, aiModelOptions, generateId } from "@/lib/ai/aiConfigList";
describe("generateId", () => {
afterEach(() => {
@ -22,3 +22,43 @@ describe("generateId", () => {
expect(generateId()).toBe("00000000-0000-4000-8000-000000000000");
});
});
describe("AI model options", () => {
it("keeps saved models selectable when provider model discovery fails", () => {
const options = aiModelOptions(
{
model: "ark-code-latest",
models: [{ name: "doubao-seed-2.0-code", label: "Doubao Code" }],
},
[],
);
expect(options).toEqual([
{ id: "ark-code-latest", displayName: undefined, supportedEffortLevels: undefined, effortCapability: undefined },
{ id: "doubao-seed-2.0-code", displayName: "Doubao Code", supportedEffortLevels: undefined, effortCapability: undefined },
]);
});
it("persists a manual model only once", () => {
expect(addConfiguredAiModel([{ name: "ark-code-latest" }], " doubao-seed-2.0-code ")).toEqual([{ name: "ark-code-latest" }, { name: "doubao-seed-2.0-code" }]);
expect(addConfiguredAiModel([{ name: "ark-code-latest" }], "ark-code-latest")).toEqual([{ name: "ark-code-latest" }]);
});
it("merges saved metadata with discovered capabilities", () => {
const options = aiModelOptions(
{
model: "",
models: [{ name: "gpt-4o", label: "GPT-4o", supportedEffortLevels: ["high"] }],
},
[{ id: "gpt-4o", displayName: "Discovered GPT-4o", effortCapability: { kind: "unsupported" } }],
);
expect(options).toEqual([{ id: "gpt-4o", displayName: "GPT-4o", supportedEffortLevels: ["high"], effortCapability: { kind: "unsupported" } }]);
});
it("handles an empty configured model and blank manual ids", () => {
expect(aiModelOptions({ model: "", models: [{ name: "a-model" }] }, [])).toEqual([{ id: "a-model", displayName: undefined, supportedEffortLevels: undefined, effortCapability: undefined }]);
expect(addConfiguredAiModel(undefined, "new-model")).toEqual([{ name: "new-model" }]);
expect(addConfiguredAiModel([{ name: "existing" }], " ")).toEqual([{ name: "existing" }]);
});
});

View File

@ -1,4 +1,5 @@
import type { AiConfig, AiConfigItem } from "@/types/ai";
import type { AiConfig, AiConfigItem, AiConfiguredModel } from "@/types/ai";
import type { AiModelInfo } from "@/lib/backend/tauri";
import { uuid } from "@/lib/common/utils";
export type { AiConfigItem };
@ -20,6 +21,46 @@ export function aiConfigToItem(config: AiConfig, id: string, name: string): AiCo
};
}
/**
* Combines models saved by the user with models returned by a provider's
* discovery endpoint. Some OpenAI-compatible providers intentionally do not
* expose `/models`, so their saved models must remain selectable on their own.
*/
export function aiModelOptions(config: Pick<AiConfig, "model" | "models">, discovered: AiModelInfo[]): AiModelInfo[] {
const saved: AiModelInfo[] = [
config.model.trim() ? { id: config.model.trim() } : null,
...(config.models ?? []).map((model) => ({
id: model.name.trim(),
displayName: model.label?.trim() || undefined,
supportedEffortLevels: model.supportedEffortLevels,
})),
].filter((model): model is AiModelInfo => Boolean(model?.id));
const options = new Map<string, AiModelInfo>();
for (const model of [...saved, ...discovered]) {
const id = model.id.trim();
if (!id) continue;
const existing = options.get(id);
options.set(id, {
...model,
id,
displayName: existing?.displayName ?? model.displayName,
supportedEffortLevels: existing?.supportedEffortLevels ?? model.supportedEffortLevels,
effortCapability: model.effortCapability ?? existing?.effortCapability,
});
}
return [...options.values()];
}
/** Add a manually entered model once while retaining its optional display metadata. */
export function addConfiguredAiModel(models: AiConfiguredModel[] | undefined, modelId: string): AiConfiguredModel[] {
const id = modelId.trim();
if (!id) return models ?? [];
const existing = models ?? [];
if (existing.some((model) => model.name.trim() === id)) return existing;
return [...existing, { name: id }];
}
export type ConfigNameValidationResult = "empty" | "duplicate" | "valid";
export function validateConfigName(name: string, configs: AiConfigItem[], excludeId?: string): ConfigNameValidationResult {