fix(ai): preserve saved and manual models
This commit is contained in:
parent
2220eed285
commit
b053adf175
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue