feat(ai): unify model discovery and effort handling

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-07-28 21:37:55 +08:00
1 parent c54b95e274
commit c7e7b7c9d9
41 files changed
+3291 -634

No files matched your search

@@ -10,6 +10,7 @@ import {
AlertTriangle,
Bot,
Check,
ChevronLeft,
ChevronRight,
CircleSlash,
Copy,
@@ -39,7 +40,7 @@ import {
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore";
@@ -54,9 +55,10 @@ import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai";
import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import { effortPreferenceUpdateForCapability, effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
import type { AgentEvent } from "@/lib/backend/tauri";
@@ -70,6 +72,7 @@ import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
import { aiCancelStream, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation } from "@/lib/backend/api";
import type { AiMessage } from "@/lib/backend/api";
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
@@ -303,16 +306,24 @@ function onEditKeydown(event: KeyboardEvent, visibleIndex: number) {
// Inline model selector
const providerSelectorOpen = ref(false);
const modelSearchQuery = ref("");
const collapsedModelConfigIds = ref<Set<string>>(new Set());
const effortMenuOpen = ref(false);
const manualModelConfigId = ref("");
const manualModelId = ref("");
const effortTextValue = ref("");
const effortIntegerValue = ref(0);
let effortMenuCloseTimer: ReturnType<typeof setTimeout> | null = null;
const { catalogs: modelCatalogs, effortCatalogs, loadModels, resolveEffort, effortKey } = useAiModelCatalog();
// Configured providers for quick switching - get from aiConfigs
const configuredProviders = computed(() => {
const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey)));
// Apply search filter - hide providers with no matching models
if (modelSearchQuery.value.trim()) {
const query = modelSearchQuery.value.trim().toLowerCase();
return providers.filter((c) => {
if (configMatchesModelQuery(c, query)) return true;
const models = getModelsForConfig(c.id);
return models.some((model) => model.toLowerCase().includes(query));
return models.some((model) => model.id.toLowerCase().includes(query) || model.displayName?.toLowerCase().includes(query));
});
}
return providers;
@@ -323,39 +334,184 @@ const activeFullConfig = computed(() => {
const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId);
if (!item) return null;
const modelId = settings.activeModel.modelId;
const config = normalizeAiConfig({ ...item, model: modelId });
if (config.provider === "claude-code-cli") {
config.reasoningLevel = normalizeClaudeCodeReasoningLevel(
config.reasoningLevel,
item.models?.find((model) => model.name === modelId),
);
}
return config;
return normalizeAiConfig({ ...item, model: modelId, runtimeEffort: runtimeEffortFromPreference(settings.activeEffort) });
});
function getModelsForConfig(configId: string): string[] {
const config = settings.aiConfigs.find((c) => c.id === configId);
if (!config) return [];
return getAiConfigModelIds(config);
function getModelsForConfig(configId: string) {
return modelCatalogs.get(configId)?.models ?? [];
}
function getConfigModelOptionIds(configId: string): string[] {
const config = settings.aiConfigs.find((c) => c.id === configId);
if (!config) return [];
let models = getModelsForConfig(configId);
// Apply search filter
if (modelSearchQuery.value.trim()) {
const query = modelSearchQuery.value.trim().toLowerCase();
models = models.filter((model) => model.toLowerCase().includes(query));
function configMatchesModelQuery(config: AiConfigItem, query: string): boolean {
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query);
}
function getConfigModelOptions(config: AiConfigItem) {
const models = getModelsForConfig(config.id);
const query = modelSearchQuery.value.trim().toLowerCase();
if (!query || configMatchesModelQuery(config, query)) return models;
return models.filter((model) => model.id.toLowerCase().includes(query) || model.displayName?.toLowerCase().includes(query));
}
function getModelCatalog(configId: string) {
return modelCatalogs.get(configId) ?? { status: "idle" as const, models: [] };
}
function isModelConfigCollapsed(configId: string): boolean {
return collapsedModelConfigIds.value.has(configId);
}
function toggleModelConfig(configId: string) {
const next = new Set(collapsedModelConfigIds.value);
if (next.has(configId)) next.delete(configId);
else next.add(configId);
collapsedModelConfigIds.value = next;
}
async function loadConfiguredModelCatalogs(force = false) {
const configs = settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey));
const queue = [...configs];
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length) {
const config = queue.shift();
if (!config) return;
await loadModels(config, force).catch(() => {});
}
});
await Promise.all(workers);
}
watch(providerSelectorOpen, (open) => {
if (open) {
void loadConfiguredModelCatalogs();
} else {
modelSearchQuery.value = "";
closeEffortMenu();
manualModelConfigId.value = "";
manualModelId.value = "";
}
});
async function ensureModelEffort(config: AiConfigItem, modelId: string, force = false) {
try {
const capability = await resolveEffort(config, modelId, force);
const isActiveModel = settings.activeModel?.configId === config.id && settings.activeModel.modelId === modelId;
if (isActiveModel) {
const preferenceUpdate = effortPreferenceUpdateForCapability(capability, settings.activeEffort);
if (preferenceUpdate !== undefined) settings.updateActiveEffort(preferenceUpdate);
}
syncEffortInputs(capability);
} catch {
// The effort section exposes the scoped retry state.
}
return models;
}
function handleModelSelect(configId: string, modelId: string) {
const config = settings.aiConfigs.find((c) => c.id === configId);
if (!config) return;
settings.updateActiveModel({ configId, modelId });
providerSelectorOpen.value = false;
closeEffortMenu();
}
function startManualModel(configId: string) {
manualModelConfigId.value = configId;
manualModelId.value = settings.activeModel?.configId === configId ? settings.activeModel.modelId : "";
nextTick(() => document.querySelector<HTMLInputElement>("[data-manual-model-input]")?.focus());
}
function applyManualModel(configId: string) {
const modelId = manualModelId.value.trim();
if (!modelId) return;
handleModelSelect(configId, modelId);
manualModelConfigId.value = "";
manualModelId.value = "";
}
const activeEffortEntry = computed(() => {
const active = settings.activeModel;
if (!active) return undefined;
return effortCatalogs.get(effortKey(active.configId, active.modelId));
});
const activeEffortCapability = computed(() => activeEffortEntry.value?.capability);
function syncEffortInputs(capability = activeEffortCapability.value) {
const selection = settings.activeEffort;
effortTextValue.value = selection?.kind === "text" ? selection.value : "";
if (capability?.kind === "integer") {
const selectedValue = selection?.kind === "integer" ? selection.value : undefined;
const defaultValue = capability.default.kind === "integer" ? capability.default.value : undefined;
effortIntegerValue.value = selectedValue !== undefined && selectedValue >= capability.min && selectedValue <= capability.max ? selectedValue : defaultValue !== undefined && defaultValue >= capability.min && defaultValue <= capability.max ? defaultValue : capability.min;
}
}
function clearEffortMenuCloseTimer() {
if (!effortMenuCloseTimer) return;
clearTimeout(effortMenuCloseTimer);
effortMenuCloseTimer = null;
}
function openEffortMenu() {
clearEffortMenuCloseTimer();
if (settings.activeModel) effortMenuOpen.value = true;
}
function closeEffortMenu() {
clearEffortMenuCloseTimer();
effortMenuOpen.value = false;
}
function scheduleEffortMenuClose() {
clearEffortMenuCloseTimer();
effortMenuCloseTimer = setTimeout(() => {
effortMenuOpen.value = false;
effortMenuCloseTimer = null;
}, 120);
}
watch(effortMenuOpen, (open) => {
const active = settings.activeModel;
if (!open || !active) return;
const config = settings.aiConfigs.find((item) => item.id === active.configId);
if (config) void ensureModelEffort(config, active.modelId);
});
function selectEffort(selection: AiEffortSelection) {
settings.updateActiveEffort(selection);
syncEffortInputs();
}
function selectEffortOption(option: AiEffortOption) {
selectEffort(option.selection);
}
function commitIntegerEffort(capability: Extract<AiEffortCapability, { kind: "integer" }>) {
const steppedValue = capability.min + Math.round((effortIntegerValue.value - capability.min) / capability.step) * capability.step;
const value = Math.min(capability.max, Math.max(capability.min, steppedValue));
effortIntegerValue.value = value;
selectEffort({ kind: "integer", value });
}
function commitTextEffort() {
const value = effortTextValue.value.trim();
settings.updateActiveEffort(value ? { kind: "text", value } : { kind: "providerDefault" });
}
function effortSelectionLabel(selection: AiEffortSelection | null): string {
if (!selection || selection.kind === "providerDefault") return t("ai.providerDefault");
const capability = activeEffortCapability.value;
const options = capability?.kind === "enum" ? capability.options : capability?.kind === "integer" ? capability.specialValues : undefined;
const matchingOption = options?.find((option) => effortSelectionEquals(selection, option.selection));
if (matchingOption) return matchingOption.label;
if (selection.kind === "disabled") return t("ai.effortDisabled");
if (selection.kind === "boolean") return selection.value ? t("ai.effortEnabled") : t("ai.effortDisabled");
return String(selection.value);
}
function retryActiveEffort() {
const active = settings.activeModel;
if (!active) return;
const config = settings.aiConfigs.find((item) => item.id === active.configId);
if (config) void ensureModelEffort(config, active.modelId, true);
}
/** Deferred context compaction info; applied after stream ends to avoid shifting assistantIdx. */
@@ -1882,6 +2038,7 @@ function stopResize() {
onUnmounted(() => {
if (assistantDeltaFrame !== null) cancelAnimationFrame(assistantDeltaFrame);
clearTimeout(mentionTimer);
clearEffortMenuCloseTimer();
cancelStream();
detachMessageScrollListener();
// 清理拖拽事件监听,防止内存泄漏
@@ -2408,51 +2565,188 @@ async function openExternalUrl(url: string) {
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-80 gap-0 p-1.5"
@open-auto-focus.prevent
@update:open="
(open: boolean) => {
if (!open) modelSearchQuery = '';
}
"
>
<!-- Search input -->
<PopoverContent align="end" class="max-h-(--reka-popover-content-available-height) w-80 gap-0 overflow-y-auto p-1.5" @open-auto-focus.prevent>
<div class="relative px-1 pb-1">
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input v-model="modelSearchQuery" type="text" :placeholder="t('ai.searchModels')" class="w-full rounded-sm border bg-background py-1.5 pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-primary" @click.stop />
</div>
<!-- All configured providers with their models -->
<div class="max-h-80 overflow-auto">
<template v-for="config in configuredProviders" :key="config.id">
<!-- Provider header -->
<div class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs" :class="config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : 'text-foreground'">
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-muted"
:class="config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : 'text-foreground'"
:aria-expanded="!isModelConfigCollapsed(config.id)"
@click="toggleModelConfig(config.id)"
>
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': !isModelConfigCollapsed(config.id) }" />
<AiProviderLogo :provider="config.provider" :label="AI_PROVIDER_PRESETS[config.provider]?.label ?? config.provider" :icon-slug="AI_PROVIDER_PRESETS[config.provider]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
<span class="font-medium">{{ config.name }}</span>
<span class="min-w-0 flex-1 truncate font-medium">{{ config.name }}</span>
<Loader2 v-if="getModelCatalog(config.id).status === 'loading'" class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
<span v-if="config.isDefault" class="ml-auto text-[10px] text-muted-foreground">{{ t("ai.default") }}</span>
</div>
<!-- No models hint -->
<div v-if="!getConfigModelOptionIds(config.id).length" class="px-2 py-2 text-xs text-muted-foreground">
{{ t("ai.noModel") }}
</div>
<!-- Model list -->
<template v-else>
<button
v-for="modelId in getConfigModelOptionIds(config.id)"
:key="modelId"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
:class="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : ''"
@click="handleModelSelect(config.id, modelId)"
>
<span class="min-w-0 flex-1 truncate">{{ modelId }}</span>
<Check v-if="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId" class="h-3.5 w-3.5 shrink-0 text-primary" />
</button>
<div v-if="!isModelConfigCollapsed(config.id)">
<div v-if="getModelCatalog(config.id).status === 'loading' && !getModelsForConfig(config.id).length" class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("ai.loadingModels") }}
</div>
<div v-else-if="getModelCatalog(config.id).status === 'error' && !getModelsForConfig(config.id).length" class="space-y-1 px-2 py-2 text-xs text-muted-foreground">
<div class="truncate" :title="getModelCatalog(config.id).error">{{ t("ai.modelLoadFailed") }}</div>
<button type="button" class="text-primary hover:underline" @click="loadModels(config, true)">{{ t("ai.retry") }}</button>
</div>
<div v-else-if="getModelCatalog(config.id).status === 'ready' && !getConfigModelOptions(config).length" class="px-2 py-2 text-xs text-muted-foreground">
{{ modelSearchQuery.trim() ? t("ai.noModelMatch") : t("ai.noModels") }}
</div>
<template v-if="getConfigModelOptions(config).length">
<button
v-for="model in getConfigModelOptions(config)"
:key="model.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
:class="model.id === settings.activeModel?.modelId && config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : ''"
@click="handleModelSelect(config.id, model.id)"
>
<span class="min-w-0 flex-1 truncate">
{{ model.displayName || model.id }}
<span v-if="model.displayName && model.displayName !== model.id" class="ml-1 text-[10px] text-muted-foreground">{{ model.id }}</span>
</span>
<Check v-if="model.id === settings.activeModel?.modelId && config.id === settings.activeModel?.configId" class="h-3.5 w-3.5 shrink-0 text-primary" />
</button>
</template>
<div v-if="getModelCatalog(config.id).status === 'error' && getModelsForConfig(config.id).length" class="flex items-center justify-between gap-2 px-2 py-1 text-[10px] text-muted-foreground">
<span class="truncate" :title="getModelCatalog(config.id).error">{{ t("ai.modelLoadFailed") }}</span>
<button type="button" class="shrink-0 text-primary hover:underline" @click="loadModels(config, true)">{{ t("ai.retry") }}</button>
</div>
<form v-if="manualModelConfigId === config.id" class="flex items-center gap-1 px-2 py-1" @submit.prevent="applyManualModel(config.id)">
<input v-model="manualModelId" data-manual-model-input type="text" :placeholder="t('ai.manualModelPlaceholder')" class="min-w-0 flex-1 rounded-sm border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary" @click.stop />
<Button type="submit" size="sm" class="h-6 px-2 text-[10px]" :disabled="!manualModelId.trim()">{{ t("common.confirm") }}</Button>
</form>
<button v-else type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" @click="startManualModel(config.id)">
<Pencil class="h-3 w-3" />
{{ t("ai.manualModel") }}
</button>
</template>
</div>
<div class="my-1 border-t" />
</template>
</div>
<div v-if="settings.activeModel" class="border-t pt-1">
<Popover v-model:open="effortMenuOpen">
<PopoverAnchor as-child>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
:aria-expanded="effortMenuOpen"
aria-haspopup="menu"
@mouseenter="openEffortMenu"
@mouseleave="scheduleEffortMenuClose"
@focus="openEffortMenu"
@click.stop="openEffortMenu"
>
<ChevronLeft class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("ai.effort") }}</span>
<span class="ml-auto max-w-[160px] truncate text-muted-foreground">{{ effortSelectionLabel(settings.activeEffort) }}</span>
</button>
</PopoverAnchor>
<PopoverContent
side="left"
align="end"
:side-offset="6"
:collision-padding="8"
class="max-h-(--reka-popover-content-available-height) w-72 gap-1 overflow-y-auto p-2"
@mouseenter="openEffortMenu"
@mouseleave="scheduleEffortMenuClose"
@open-auto-focus.prevent
@close-auto-focus.prevent
@pointerdown.stop
@click.stop
@keydown.stop
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
:class="!settings.activeEffort || settings.activeEffort.kind === 'providerDefault' ? 'bg-accent text-accent-foreground' : ''"
@click="selectEffort({ kind: 'providerDefault' })"
>
<span class="flex-1">{{ t("ai.providerDefault") }}</span>
<Check v-if="!settings.activeEffort || settings.activeEffort.kind === 'providerDefault'" class="h-3.5 w-3.5 text-primary" />
</button>
<div v-if="activeEffortEntry?.status === 'loading'" class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("ai.loadingEffort") }}
</div>
<div v-else-if="activeEffortEntry?.status === 'error'" class="flex items-center justify-between gap-2 py-2 text-xs text-muted-foreground">
<span class="truncate" :title="activeEffortEntry.error">{{ t("ai.effortLoadFailed") }}</span>
<button type="button" class="shrink-0 text-primary hover:underline" @click="retryActiveEffort">
{{ t("ai.retry") }}
</button>
</div>
<template v-else-if="activeEffortCapability?.kind === 'enum'">
<button
v-for="option in activeEffortCapability.options"
:key="option.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
:class="effortSelectionEquals(settings.activeEffort, option.selection) ? 'bg-accent text-accent-foreground' : ''"
@click="selectEffortOption(option)"
>
<span class="flex-1">{{ option.label }}</span>
<Check v-if="effortSelectionEquals(settings.activeEffort, option.selection)" class="h-3.5 w-3.5 text-primary" />
</button>
</template>
<template v-else-if="activeEffortCapability?.kind === 'integer'">
<button
v-for="option in activeEffortCapability.specialValues"
:key="option.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
:class="effortSelectionEquals(settings.activeEffort, option.selection) ? 'bg-accent text-accent-foreground' : ''"
@click="selectEffortOption(option)"
>
<span class="flex-1">{{ option.label }}</span>
<Check v-if="effortSelectionEquals(settings.activeEffort, option.selection)" class="h-3.5 w-3.5 text-primary" />
</button>
<div class="flex items-center gap-2 py-1">
<input v-model.number="effortIntegerValue" type="range" class="min-w-0 flex-1" :min="activeEffortCapability.min" :max="activeEffortCapability.max" :step="activeEffortCapability.step" @change="commitIntegerEffort(activeEffortCapability)" />
<input
v-model.number="effortIntegerValue"
type="number"
class="w-20 rounded-sm border bg-background px-2 py-1 text-xs"
:min="activeEffortCapability.min"
:max="activeEffortCapability.max"
:step="activeEffortCapability.step"
@change="commitIntegerEffort(activeEffortCapability)"
@click.stop
/>
</div>
</template>
<template v-else-if="activeEffortCapability?.kind === 'boolean'">
<button type="button" class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent" @click="selectEffort({ kind: 'boolean', value: true })">
<span class="flex-1 text-left">{{ t("ai.effortEnabled") }}</span>
<Check v-if="settings.activeEffort?.kind === 'boolean' && settings.activeEffort.value" class="h-3.5 w-3.5 text-primary" />
</button>
<button type="button" class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent" @click="selectEffort({ kind: 'boolean', value: false })">
<span class="flex-1 text-left">{{ t("ai.effortDisabled") }}</span>
<Check v-if="settings.activeEffort?.kind === 'boolean' && !settings.activeEffort.value" class="h-3.5 w-3.5 text-primary" />
</button>
</template>
<form v-else-if="activeEffortCapability?.kind === 'freeText'" class="flex items-center gap-1 py-1" @submit.prevent="commitTextEffort">
<input
v-model="effortTextValue"
type="text"
maxlength="64"
:placeholder="activeEffortCapability.placeholder || t('ai.customEffortPlaceholder')"
class="min-w-0 flex-1 rounded-sm border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary"
@click.stop
@blur="commitTextEffort"
/>
<Button type="submit" size="sm" class="h-6 px-2 text-[10px]">{{ t("common.confirm") }}</Button>
</form>
<div v-else-if="activeEffortCapability?.kind === 'unsupported'" class="px-2 py-2 text-xs text-muted-foreground">
{{ t("ai.effortUnsupported") }}
</div>
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
</template>
@@ -12,7 +12,6 @@ import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
@@ -33,7 +32,6 @@ import {
type AiApiStyle,
type AiAuthMethod,
type AiConfiguredModel,
type AiEffortLevel,
type AiReasoningLevel,
type EditorTheme,
type DesktopIconTheme,
@@ -49,7 +47,6 @@ import {
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
@@ -81,7 +78,6 @@ import {
webdavSyncTest,
webdavSyncUpload,
type AppSupportInfo,
type AiModelInfo,
type McpServerStatus,
type SnippetProvider,
type SnippetSyncConfig,
@@ -2199,16 +2195,6 @@ async function saveMaxAgentTurnsSetting() {
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
// Model list management
const aiEditModels = ref<AiConfiguredModel[]>([]);
const aiModelListLoading = ref(false);
let aiModelListRequestToken = 0;
// AI Model Multi-Select
const aiModelMultiSelectOpen = ref(false);
const aiModelMultiSelectSearch = ref("");
const aiFetchedModels = ref<AiModelInfo[]>([]);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
@@ -2218,6 +2204,7 @@ const aiEditApiKey = ref("");
const aiEditAuthMethod = ref<AiAuthMethod>("api-key");
const aiEditEndpoint = ref("");
const aiEditModel = ref("");
const aiEditLegacyModels = ref<AiConfiguredModel[]>([]);
const aiEditApiStyle = ref<AiApiStyle>("completions");
const aiEditProxyEnabled = ref(false);
const aiEditProxyUrl = ref("");
@@ -2229,24 +2216,7 @@ const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditClaudeCodeCliPath = ref("");
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiModelError = ref("");
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
const codexReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [
{ value: "default", labelKey: "ai.reasoningLevelDefault" },
{ value: "minimal", labelKey: "ai.reasoningLevelMinimal" },
{ value: "low", labelKey: "ai.reasoningLevelLow" },
{ value: "medium", labelKey: "ai.reasoningLevelMedium" },
{ value: "high", labelKey: "ai.reasoningLevelHigh" },
];
const effortLevelLabelKeys: Record<AiEffortLevel, string> = {
low: "ai.reasoningLevelLow",
medium: "ai.reasoningLevelMedium",
high: "ai.reasoningLevelHigh",
xhigh: "ai.reasoningLevelXhigh",
max: "ai.reasoningLevelMax",
};
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
@@ -2329,8 +2299,6 @@ const aiCliMcpActionLabel = computed(() => {
if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton");
return t("settings.mcpUpToDate");
});
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCliProvider.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
const aiCliEnvError = computed(() => cliEnvValidationError());
const aiCliPathError = computed(() => {
const path = aiEditCliPath.value.trim();
@@ -2375,18 +2343,21 @@ function removeCliEnvRow(id: string) {
}
function currentAiEditConfig() {
const reasoningLevel = aiIsClaudeCodeCli.value ? normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value) : aiEditReasoningLevel.value;
return {
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value.trim(),
authMethod: aiEditAuthMethod.value,
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
models: aiEditLegacyModels.value.map((model) => ({
...model,
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
})),
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel,
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value || undefined,
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {},
@@ -2412,16 +2383,12 @@ function aiSelectProvider(provider: AiProvider) {
aiEditApiKey.value = "";
aiEditAuthMethod.value = preset.authMethod;
aiEditEndpoint.value = preset.endpoint;
aiEditModel.value = preset.model;
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditApiStyle.value = preset.apiStyle;
aiEditEnableThinking.value = true;
aiEditReasoningLevel.value = "default";
aiEditModels.value = [];
aiFetchedModels.value = [];
aiLastModelFetchSignature = "";
aiModelListRequestToken += 1;
aiModelListLoading.value = false;
if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus();
if (provider === "claude-code-cli") void aiFetchModelList();
}
function aiSelectApiStyle(style: AiApiStyle) {
@@ -2449,6 +2416,10 @@ function aiEnterEditMode(configId?: string) {
aiEditAuthMethod.value = config.authMethod;
aiEditEndpoint.value = config.endpoint;
aiEditModel.value = config.model;
aiEditLegacyModels.value = (config.models ?? []).map((model) => ({
...model,
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
}));
aiEditApiStyle.value = config.apiStyle;
aiEditProxyEnabled.value = config.proxyEnabled ?? false;
aiEditProxyUrl.value = config.proxyUrl ?? "";
@@ -2459,16 +2430,15 @@ function aiEnterEditMode(configId?: string) {
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? "";
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
aiEditModels.value = config.models ? config.models.map((model) => ({ ...model, supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined })) : [];
}
} else {
aiEditConfigName.value = "";
aiEditModels.value = [];
aiEditProvider.value = "claude";
aiEditApiKey.value = "";
aiEditAuthMethod.value = AI_PROVIDER_PRESETS["claude"].authMethod;
aiEditEndpoint.value = AI_PROVIDER_PRESETS["claude"].endpoint;
aiEditModel.value = AI_PROVIDER_PRESETS["claude"].model;
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditApiStyle.value = AI_PROVIDER_PRESETS["claude"].apiStyle;
aiEditProxyEnabled.value = false;
aiEditProxyUrl.value = "";
@@ -2480,194 +2450,6 @@ function aiEnterEditMode(configId?: string) {
aiEditClaudeCodeCliPath.value = "";
aiEditClaudeCodeCliEnvRows.value = [];
}
aiFetchedModels.value = [];
aiLastModelFetchSignature = "";
if (aiIsClaudeCodeCli.value) void aiFetchModelList();
}
function aiAddModel() {
aiEditModels.value.push({ name: "", label: "" });
}
function aiRemoveModel(index: number) {
aiEditModels.value.splice(index, 1);
}
async function aiFetchModelList() {
if (aiModelListLoading.value) return;
if (!aiCanListModels.value) return;
const token = ++aiModelListRequestToken;
const signature = aiModelFetchSignature.value;
aiModelListLoading.value = true;
aiModelError.value = "";
try {
const models = await aiListModels({
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value.trim(),
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
authMethod: aiEditAuthMethod.value,
apiStyle: aiEditApiStyle.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value,
codexCliPath: aiEditCodexCliPath.value,
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
claudeCodeCliPath: aiEditClaudeCodeCliPath.value,
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
});
if (token !== aiModelListRequestToken) return;
aiFetchedModels.value = normalizeAiModelOptions(models);
aiLastModelFetchSignature = signature;
const fetchedById = new Map(aiFetchedModels.value.map((model) => [model.id, model]));
aiEditModels.value = aiEditModels.value.map((model) => {
const fetched = fetchedById.get(model.name);
if (!fetched) return model;
return {
name: model.name,
label: fetched.displayName || model.label,
supportedEffortLevels: fetched.supportedEffortLevels,
};
});
if (aiIsClaudeCodeCli.value) {
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
}
} catch (e: any) {
if (token === aiModelListRequestToken) {
aiModelError.value = e?.message || String(e);
}
} finally {
if (token === aiModelListRequestToken) aiModelListLoading.value = false;
}
}
function aiIsModelSelected(modelId: string): boolean {
return aiEditModels.value.some((m) => m.name === modelId);
}
function aiToggleModel(model: AiModelInfo) {
const index = aiEditModels.value.findIndex((m) => m.name === model.id);
if (index >= 0) {
aiEditModels.value.splice(index, 1);
} else {
aiEditModels.value.push({
name: model.id,
label: model.displayName || model.id,
supportedEffortLevels: normalizeAiModelEffortLevels(model.supportedEffortLevels),
});
}
}
const aiFilteredFetchedModels = computed(() => {
const search = aiModelMultiSelectSearch.value.trim().toLowerCase();
if (!search) return aiFetchedModels.value;
return aiFetchedModels.value.filter((m) => m.id.toLowerCase().includes(search) || (m.displayName && m.displayName.toLowerCase().includes(search)));
});
const aiModelFetchSignature = computed(() =>
JSON.stringify({
provider: aiEditProvider.value,
endpoint: aiEditEndpoint.value.trim(),
apiKey: aiEditApiKey.value.trim(),
authMethod: aiEditAuthMethod.value,
apiStyle: aiEditApiStyle.value,
codexCliPath: aiEditCodexCliPath.value.trim(),
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim(),
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
}),
);
let aiLastModelFetchSignature = "";
function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] {
const seen = new Set<string>();
const normalized: AiModelInfo[] = [];
for (const model of models) {
const id = model.id?.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
normalized.push({
id,
displayName: model.displayName?.trim() || undefined,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
});
}
return normalized;
}
const aiSelectedModelInfo = computed<AiModelInfo | undefined>(() => {
if (aiModelFetchSignature.value === aiLastModelFetchSignature) {
const fetched = aiFetchedModels.value.find((model) => model.id === aiEditModel.value);
if (fetched) return fetched;
}
const configured = aiEditModels.value.find((model) => model.name === aiEditModel.value);
if (!configured) return undefined;
return {
id: configured.name,
displayName: configured.label,
supportedEffortLevels: configured.supportedEffortLevels,
};
});
const aiClaudeCodeEffortLevels = computed(() => normalizeAiModelEffortLevels(aiSelectedModelInfo.value?.supportedEffortLevels));
const aiReasoningLevelOptions = computed<Array<{ value: AiReasoningLevel; labelKey: string }>>(() => {
if (!aiIsClaudeCodeCli.value) return codexReasoningLevelOptions;
return [{ value: "default", labelKey: "ai.reasoningLevelDefault" }, ...aiClaudeCodeEffortLevels.value.map((value) => ({ value, labelKey: effortLevelLabelKeys[value] }))];
});
const aiReasoningLevelDisabled = computed(() => aiIsClaudeCodeCli.value && aiClaudeCodeEffortLevels.value.length === 0);
const aiReasoningLevelHint = computed(() => {
if (!aiIsClaudeCodeCli.value) return t("ai.reasoningLevelHint");
if (aiModelListLoading.value && aiClaudeCodeEffortLevels.value.length === 0) return t("ai.loadingModels");
return aiClaudeCodeEffortLevels.value.length ? t("ai.claudeCodeEffortHint") : t("ai.claudeCodeEffortUnavailable");
});
watch([aiIsClaudeCodeCli, aiSelectedModelInfo], () => {
if (!aiIsClaudeCodeCli.value) return;
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
});
function aiModelsForSave(): AiConfiguredModel[] | undefined {
const models = aiEditModels.value
.map((model) => {
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
return {
name: model.name.trim(),
label: model.label?.trim() || undefined,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
};
})
.filter((model) => model.name);
if (aiIsClaudeCodeCli.value && aiEditModel.value.trim() && aiSelectedModelInfo.value) {
const modelId = aiEditModel.value.trim();
const supportedEffortLevels = normalizeAiModelEffortLevels(aiSelectedModelInfo.value.supportedEffortLevels);
const configuredModel = models.find((model) => model.name === modelId);
if (configuredModel) {
configuredModel.label = aiSelectedModelInfo.value.displayName || configuredModel.label;
configuredModel.supportedEffortLevels = supportedEffortLevels.length ? supportedEffortLevels : undefined;
} else {
models.unshift({
name: modelId,
label: aiSelectedModelInfo.value.displayName,
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
});
}
}
return models.length ? models : undefined;
}
function aiOnModelPopoverOpen(open: boolean) {
if (!open) return;
if (aiModelFetchSignature.value !== aiLastModelFetchSignature) {
aiFetchedModels.value = [];
if (aiCanListModels.value) void aiFetchModelList();
} else if (aiFetchedModels.value.length === 0 && aiCanListModels.value) {
void aiFetchModelList();
}
}
async function aiSaveConfig() {
@@ -2682,13 +2464,10 @@ async function aiSaveConfig() {
}
const editConfig = currentAiEditConfig();
aiEditReasoningLevel.value = editConfig.reasoningLevel;
const models = aiModelsForSave();
const config: AiConfigItem = {
id: aiEditConfigId.value || generateId(),
name: aiEditConfigName.value,
...editConfig,
models,
};
try {
@@ -2735,7 +2514,7 @@ async function aiSetDefaultConfig(id: string) {
}
async function aiTestConn() {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim()) || (!aiIsCliProvider.value && !aiEditModel.value.trim())) return;
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim())) return;
if (aiCliValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiCliValidationError.value;
@@ -2747,9 +2526,16 @@ async function aiTestConn() {
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
try {
const result = await aiTestConnection(currentAiEditConfig());
const config = currentAiEditConfig();
if (aiIsCliProvider.value) {
const result = await aiTestConnection(config);
aiTestLatency.value = result.latencyMs ?? null;
} else {
const startedAt = performance.now();
await aiListModels(config);
aiTestLatency.value = Math.round(performance.now() - startedAt);
}
aiTestResult.value = "success";
aiTestLatency.value = result.latencyMs ?? null;
} catch (e: any) {
aiTestResult.value = "error";
aiTestError.value = translateBackendError(t, e?.message || String(e));
@@ -4828,7 +4614,7 @@ onUnmounted(cleanupPreviewEditor);
<span class="text-sm font-medium">{{ config.name }}</span>
<Badge v-if="config.isDefault" variant="default" class="h-5 text-[10px]"> {{ t("ai.default") }} </Badge>
</div>
<div class="text-xs text-muted-foreground">{{ AI_PROVIDER_PRESETS[config.provider].label }} - {{ config.model }}</div>
<div class="text-xs text-muted-foreground">{{ AI_PROVIDER_PRESETS[config.provider].label }}</div>
</div>
</div>
<div class="flex items-center gap-1">
@@ -5084,80 +4870,6 @@ onUnmounted(cleanupPreviewEditor);
</div>
</div>
<!-- Model -->
<div class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.defaultModel") }}</Label>
<div class="col-span-2">
<Input v-model="aiEditModel" autocomplete="off" class="h-8 text-xs" />
</div>
</div>
<!-- Model List -->
<div class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.modelList") }}</Label>
<div class="col-span-2 space-y-2">
<div v-for="(m, index) in aiEditModels" :key="index" class="flex items-center gap-2">
<Input v-model="m.name" :placeholder="t('ai.modelId')" class="h-8 flex-1 text-xs" />
<Input v-model="m.label" :placeholder="t('ai.modelDisplayName')" class="h-8 flex-1 text-xs" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="aiRemoveModel(index)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex gap-2">
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" @click="aiAddModel">
<Plus class="mr-1 h-3 w-3" />
{{ t("ai.add") }}
</Button>
<Popover v-model:open="aiModelMultiSelectOpen" @update:open="aiOnModelPopoverOpen">
<PopoverTrigger as-child>
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" :disabled="!aiCanListModels">
<Loader2 v-if="aiModelListLoading" class="mr-1 h-3 w-3 animate-spin" />
<Download v-else class="mr-1 h-3 w-3" />
{{ aiModelListLoading ? t("ai.fetching") : t("ai.fetchModelList") }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-56 p-0" align="start">
<div class="flex flex-col">
<div class="border-b px-3 py-2">
<Input v-model="aiModelMultiSelectSearch" :placeholder="t('ai.searchModels')" class="h-8 text-xs" />
</div>
<div class="max-h-60 overflow-y-auto p-1">
<div v-if="aiFilteredFetchedModels.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
{{ aiModelListLoading ? t("ai.loadingModels") : aiModelError || t("ai.noModels") }}
</div>
<button v-for="model in aiFilteredFetchedModels" :key="model.id" type="button" class="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" @click="aiToggleModel(model)">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="aiIsModelSelected(model.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
<Check v-if="aiIsModelSelected(model.id)" class="h-3 w-3" />
</div>
<span class="flex-1 truncate text-left">{{ model.displayName || model.id }}</span>
</button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<p v-if="aiModelError" class="text-xs text-destructive">{{ aiModelError }}</p>
</div>
</div>
<!-- Reasoning Level -->
<div v-if="aiIsCodexCli || aiIsClaudeCodeCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
<div class="col-span-2 space-y-1.5">
<Select v-model="aiEditReasoningLevel" :disabled="aiReasoningLevelDisabled">
<SelectTrigger inputClass="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in aiReasoningLevelOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-[11px] text-muted-foreground">{{ aiReasoningLevelHint }}</p>
</div>
</div>
<!-- API Style -->
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
@@ -5168,25 +4880,6 @@ onUnmounted(cleanupPreviewEditor);
</div>
</div>
<!-- Enable Thinking -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
<div class="col-span-2 flex items-center gap-2">
<label class="flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="aiEditEnableThinking" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" :disabled="!aiCompletionsMode || aiEditProvider === 'gemini'" />
{{ aiEditEnableThinking ? t("ai.enableThinkingOn") : t("ai.enableThinkingOff") }}
</label>
<Popover>
<PopoverTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</PopoverTrigger>
<PopoverContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="start">
{{ t("ai.enableThinkingHint") }}
</PopoverContent>
</Popover>
</div>
</div>
<!-- Context Window -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
@@ -5691,7 +5384,7 @@ onUnmounted(cleanupPreviewEditor);
</template>
<template v-else>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim()) || (!aiIsCliProvider && !aiEditModel?.trim())" @click="aiTestConn">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim())" @click="aiTestConn">
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiConfigItem, AiEffortCapability } from "@/types/ai";
const apiMock = vi.hoisted(() => ({
aiListModels: vi.fn(),
aiResolveModelEffort: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
function config(id = "config-1"): AiConfigItem {
return {
id,
name: "OpenAI",
isDefault: true,
provider: "openai",
apiKey: "secret",
authMethod: "bearer",
endpoint: "https://api.example.com/v1",
model: "",
apiStyle: "responses",
};
}
describe("useAiModelCatalog", () => {
const catalog = useAiModelCatalog();
beforeEach(() => {
catalog.catalogs.clear();
catalog.effortCatalogs.clear();
apiMock.aiListModels.mockReset();
apiMock.aiResolveModelEffort.mockReset();
});
it("deduplicates concurrent model requests and removes duplicate model IDs", async () => {
apiMock.aiListModels.mockResolvedValue([
{ id: "gpt-5.6", displayName: "GPT 5.6" },
{ id: "gpt-5.6", displayName: "Duplicate" },
]);
const [first, second] = await Promise.all([catalog.loadModels(config()), catalog.loadModels(config())]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(1);
expect(first).toEqual([{ id: "gpt-5.6", displayName: "GPT 5.6" }]);
expect(second).toEqual(first);
expect(catalog.catalogs.get("config-1")?.status).toBe("ready");
});
it("reuses effort capability returned with the model catalog", async () => {
const capability: AiEffortCapability = {
kind: "enum",
options: [{ id: "low", label: "Low", selection: { kind: "enum", value: "low" } }],
default: { kind: "enum", value: "low" },
source: "providerApi",
};
apiMock.aiListModels.mockResolvedValue([{ id: "claude-model", effortCapability: capability }]);
await catalog.loadModels(config());
const resolved = await catalog.resolveEffort(config(), "claude-model");
expect(resolved).toEqual(capability);
expect(apiMock.aiResolveModelEffort).not.toHaveBeenCalled();
});
it("keeps a provider failure scoped and allows an explicit retry", async () => {
apiMock.aiListModels.mockRejectedValueOnce(new Error("temporary failure")).mockResolvedValueOnce([{ id: "recovered" }]);
await expect(catalog.loadModels(config())).rejects.toThrow("temporary failure");
expect(catalog.catalogs.get("config-1")).toMatchObject({ status: "error", error: "temporary failure" });
await expect(catalog.loadModels(config(), true)).resolves.toEqual([{ id: "recovered" }]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
});
it("invalidates model and effort caches when provider runtime configuration changes", async () => {
const initial = config();
const updated = { ...initial, endpoint: "https://api.changed.example.com/v1" };
apiMock.aiListModels.mockResolvedValueOnce([{ id: "old-model" }]).mockResolvedValueOnce([{ id: "new-model" }]);
apiMock.aiResolveModelEffort.mockResolvedValueOnce({ kind: "unsupported" }).mockResolvedValueOnce({
kind: "enum",
options: [{ id: "high", label: "High", selection: { kind: "enum", value: "high" } }],
default: { kind: "enum", value: "high" },
source: "providerApi",
});
await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "old-model" }]);
await expect(catalog.resolveEffort(initial, "manual-model")).resolves.toEqual({ kind: "unsupported" });
await expect(catalog.loadModels(updated)).resolves.toEqual([{ id: "new-model" }]);
await expect(catalog.resolveEffort(updated, "manual-model")).resolves.toMatchObject({ kind: "enum" });
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
expect(apiMock.aiResolveModelEffort).toHaveBeenCalledTimes(2);
});
it("does not let a stale request overwrite a newer provider catalog", async () => {
let resolveInitial: ((models: { id: string }[]) => void) | undefined;
apiMock.aiListModels
.mockReturnValueOnce(
new Promise((resolve) => {
resolveInitial = resolve;
}),
)
.mockResolvedValueOnce([{ id: "new-model" }]);
const initialRequest = catalog.loadModels(config());
const updatedRequest = catalog.loadModels({ ...config(), endpoint: "https://api.changed.example.com/v1" });
await expect(updatedRequest).resolves.toEqual([{ id: "new-model" }]);
resolveInitial?.([{ id: "old-model" }]);
await expect(initialRequest).resolves.toEqual([{ id: "old-model" }]);
expect(catalog.catalogs.get("config-1")?.models).toEqual([{ id: "new-model" }]);
});
});
@@ -0,0 +1,176 @@
import { reactive } from "vue";
import * as api from "@/lib/backend/api";
import type { AiModelInfo } from "@/lib/backend/tauri";
import type { AiConfigItem, AiEffortCapability } from "@/types/ai";
const CATALOG_TTL_MS = 5 * 60 * 1000;
export interface AiModelCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
models: AiModelInfo[];
signature?: string;
error?: string;
loadedAt?: number;
}
interface AiEffortCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
capability?: AiEffortCapability;
signature?: string;
error?: string;
loadedAt?: number;
}
const catalogs = reactive(new Map<string, AiModelCatalogEntry>());
const effortCatalogs = reactive(new Map<string, AiEffortCatalogEntry>());
const modelRequests = new Map<string, Promise<AiModelInfo[]>>();
const effortRequests = new Map<string, Promise<AiEffortCapability>>();
function effortKey(configId: string, modelId: string): string {
return JSON.stringify([configId, modelId]);
}
function sortedRecord(record: Record<string, string> | undefined): [string, string][] {
return Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right));
}
function fingerprint(value: string): string {
let first = 0xdeadbeef;
let second = 0x41c6ce57;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 2654435761);
second = Math.imul(second ^ code, 1597334677);
}
first = Math.imul(first ^ (first >>> 16), 2246822507) ^ Math.imul(second ^ (second >>> 13), 3266489909);
second = Math.imul(second ^ (second >>> 16), 2246822507) ^ Math.imul(first ^ (first >>> 13), 3266489909);
return `${(second >>> 0).toString(16).padStart(8, "0")}${(first >>> 0).toString(16).padStart(8, "0")}`;
}
function configSignature(config: AiConfigItem): string {
return JSON.stringify({
provider: config.provider,
authMethod: config.authMethod,
apiStyle: config.apiStyle,
proxyEnabled: config.proxyEnabled ?? false,
contextWindow: config.contextWindow ?? null,
codexCliPath: config.codexCliPath ?? null,
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
endpoint: config.endpoint,
proxyUrl: config.proxyUrl ?? "",
codexCliEnv: sortedRecord(config.codexCliEnv),
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
}),
),
});
}
function fresh(loadedAt: number | undefined): boolean {
return typeof loadedAt === "number" && Date.now() - loadedAt < CATALOG_TTL_MS;
}
function configPayload(config: AiConfigItem, modelId = config.model): AiConfigItem {
return { ...config, model: modelId, runtimeEffort: null };
}
async function loadModels(config: AiConfigItem, force = false): Promise<AiModelInfo[]> {
const signature = configSignature(config);
const current = catalogs.get(config.id);
if (!force && current?.status === "ready" && current.signature === signature && fresh(current.loadedAt)) {
return current.models;
}
const requestKey = JSON.stringify([config.id, signature]);
const pending = modelRequests.get(requestKey);
if (pending) return pending;
const previousModels = current?.signature === signature ? current.models : [];
catalogs.set(config.id, { status: "loading", models: previousModels, signature });
const request = api
.aiListModels(configPayload(config))
.then((models) => {
const seen = new Set<string>();
const uniqueModels = models.filter((model) => {
const id = model.id.trim();
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
if (catalogs.get(config.id)?.signature !== signature) return uniqueModels;
catalogs.set(config.id, { status: "ready", models: uniqueModels, signature, loadedAt: Date.now() });
for (const model of uniqueModels) {
if (model.effortCapability) {
effortCatalogs.set(effortKey(config.id, model.id), {
status: "ready",
capability: model.effortCapability,
signature,
loadedAt: Date.now(),
});
}
}
return uniqueModels;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (catalogs.get(config.id)?.signature === signature) {
catalogs.set(config.id, { status: "error", models: previousModels, signature, error: message });
}
throw error;
})
.finally(() => {
modelRequests.delete(requestKey);
});
modelRequests.set(requestKey, request);
return request;
}
async function resolveEffort(config: AiConfigItem, modelId: string, force = false): Promise<AiEffortCapability> {
const key = effortKey(config.id, modelId);
const signature = configSignature(config);
const current = effortCatalogs.get(key);
if (!force && current?.status === "ready" && current.signature === signature && current.capability && fresh(current.loadedAt)) {
return current.capability;
}
const requestKey = JSON.stringify([key, signature]);
const pending = effortRequests.get(requestKey);
if (pending) return pending;
const previousCapability = current?.signature === signature ? current.capability : undefined;
effortCatalogs.set(key, { status: "loading", capability: previousCapability, signature });
const request = api
.aiResolveModelEffort(configPayload(config, modelId), modelId)
.then((capability) => {
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "ready", capability, signature, loadedAt: Date.now() });
}
return capability;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "error", capability: previousCapability, signature, error: message });
}
throw error;
})
.finally(() => {
effortRequests.delete(requestKey);
});
effortRequests.set(requestKey, request);
return request;
}
export function useAiModelCatalog() {
return {
catalogs,
effortCatalogs,
loadModels,
resolveEffort,
effortKey,
};
}
+14
View File
@@ -1414,6 +1414,7 @@ export default {
stopping: "Stopping...",
close: "Close",
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
clear: "Clear",
copy: "Copy",
@@ -1599,10 +1600,23 @@ export default {
browseModels: "Browse models",
refreshModels: "Refresh models",
loadingModels: "Loading models...",
modelLoadFailed: "Failed to load models",
retry: "Retry",
searchModels: "Search models",
noModels: "No models loaded",
noModelMatch: "No matching models",
selectModel: "Select a model",
noModel: "No model configured",
manualModel: "Enter model ID manually",
manualModelPlaceholder: "Model ID",
effort: "Effort",
providerDefault: "Provider default",
loadingEffort: "Loading effort levels...",
effortLoadFailed: "Failed to load effort levels",
effortEnabled: "Enabled",
effortDisabled: "Disabled",
customEffortPlaceholder: "Enter effort value",
effortUnsupported: "This model does not support configurable effort",
configNameEmpty: "Config name cannot be empty",
configNameExists: "Config name '{name}' already exists",
+14
View File
@@ -1357,6 +1357,7 @@ export default withEnglishFallback({
stopping: "Deteniendo...",
close: "Cerrar",
cancel: "Cancelar",
confirm: "Confirmar",
save: "Guardar",
clear: "Limpiar",
copy: "Copiar",
@@ -1542,10 +1543,23 @@ export default withEnglishFallback({
browseModels: "Explorar modelos",
refreshModels: "Actualizar modelos",
loadingModels: "Cargando modelos...",
modelLoadFailed: "No se pudieron cargar los modelos",
retry: "Reintentar",
searchModels: "Buscar modelos",
noModels: "No hay modelos cargados",
noModelMatch: "No hay modelos coincidentes",
selectModel: "Seleccionar modelo",
noModel: "No hay modelo configurado",
manualModel: "Introducir ID de modelo manualmente",
manualModelPlaceholder: "ID del modelo",
effort: "Esfuerzo",
providerDefault: "Predeterminado del proveedor",
loadingEffort: "Cargando niveles de esfuerzo...",
effortLoadFailed: "No se pudieron cargar los niveles de esfuerzo",
effortEnabled: "Activado",
effortDisabled: "Desactivado",
customEffortPlaceholder: "Introducir valor de esfuerzo",
effortUnsupported: "Este modelo no admite esfuerzo configurable",
configNameEmpty: "El nombre de la configuración no puede estar vacío",
configNameExists: "El nombre de la configuración '{name}' ya existe",
+14
View File
@@ -1355,6 +1355,7 @@ export default withEnglishFallback({
stopping: "Interruzione...",
close: "Chiudi",
cancel: "Annulla",
confirm: "Conferma",
save: "Salva",
clear: "Cancella",
copy: "Copia",
@@ -1540,10 +1541,23 @@ export default withEnglishFallback({
browseModels: "Sfoglia modelli",
refreshModels: "Aggiorna modelli",
loadingModels: "Caricamento modelli...",
modelLoadFailed: "Impossibile caricare i modelli",
retry: "Riprova",
searchModels: "Cerca modelli",
noModels: "Nessun modello caricato",
noModelMatch: "Nessun modello corrispondente",
selectModel: "Seleziona modello",
noModel: "Nessun modello configurato",
manualModel: "Inserisci manualmente l'ID modello",
manualModelPlaceholder: "ID modello",
effort: "Intensità",
providerDefault: "Predefinito del provider",
loadingEffort: "Caricamento livelli di intensità...",
effortLoadFailed: "Impossibile caricare i livelli di intensità",
effortEnabled: "Abilitato",
effortDisabled: "Disabilitato",
customEffortPlaceholder: "Inserisci valore di intensità",
effortUnsupported: "Questo modello non supporta un'intensità configurabile",
configNameEmpty: "Il nome della configurazione non può essere vuoto",
configNameExists: "Il nome della configurazione '{name}' esiste già",
+14
View File
@@ -1356,6 +1356,7 @@ export default withEnglishFallback({
stopping: "停止中...",
close: "閉じる",
cancel: "キャンセル",
confirm: "確定",
save: "保存",
clear: "クリア",
retry: "再試行",
@@ -1541,10 +1542,23 @@ export default withEnglishFallback({
browseModels: "モデルを参照",
refreshModels: "モデルを更新",
loadingModels: "モデルを読み込み中...",
modelLoadFailed: "モデルの読み込みに失敗しました",
retry: "再試行",
searchModels: "モデルを検索",
noModels: "モデルが読み込まれていません",
noModelMatch: "一致するモデルがありません",
selectModel: "モデルを選択",
noModel: "モデルが設定されていません",
manualModel: "モデル ID を手動入力",
manualModelPlaceholder: "モデル ID",
effort: "推論強度",
providerDefault: "Provider のデフォルト",
loadingEffort: "推論強度を読み込み中...",
effortLoadFailed: "推論強度の読み込みに失敗しました",
effortEnabled: "有効",
effortDisabled: "無効",
customEffortPlaceholder: "推論強度を入力",
effortUnsupported: "このモデルでは推論強度を設定できません",
configNameEmpty: "設定名を入力してください",
configNameExists: "設定名 '{name}' は既に存在します",
+14
View File
@@ -1357,6 +1357,7 @@ export default withEnglishFallback({
stopping: "Parando...",
close: "Fechar",
cancel: "Cancelar",
confirm: "Confirmar",
save: "Salvar",
clear: "Limpar",
retry: "Tentar novamente",
@@ -1542,10 +1543,23 @@ export default withEnglishFallback({
browseModels: "Explorar modelos",
refreshModels: "Atualizar modelos",
loadingModels: "Carregando modelos...",
modelLoadFailed: "Falha ao carregar modelos",
retry: "Tentar novamente",
searchModels: "Pesquisar modelos",
noModels: "Nenhum modelo carregado",
noModelMatch: "Nenhum modelo correspondente",
selectModel: "Selecionar modelo",
noModel: "Nenhum modelo configurado",
manualModel: "Inserir ID do modelo manualmente",
manualModelPlaceholder: "ID do modelo",
effort: "Esforço",
providerDefault: "Padrão do provedor",
loadingEffort: "Carregando níveis de esforço...",
effortLoadFailed: "Falha ao carregar níveis de esforço",
effortEnabled: "Ativado",
effortDisabled: "Desativado",
customEffortPlaceholder: "Inserir valor de esforço",
effortUnsupported: "Este modelo não permite configurar esforço",
configNameEmpty: "O nome da configuração não pode estar vazio",
configNameExists: "O nome da configuração '{name}' já existe",
+14
View File
@@ -1415,6 +1415,7 @@ export default withEnglishFallback({
stopping: "正在停止...",
close: "关闭",
cancel: "取消",
confirm: "确定",
save: "保存",
clear: "清空",
copy: "复制",
@@ -1600,10 +1601,23 @@ export default withEnglishFallback({
browseModels: "浏览模型",
refreshModels: "刷新模型",
loadingModels: "加载模型中...",
modelLoadFailed: "模型加载失败",
retry: "重试",
searchModels: "搜索模型",
noModels: "暂无模型",
noModelMatch: "没有匹配的模型",
selectModel: "选择模型",
noModel: "未配置模型",
manualModel: "手动输入模型 ID",
manualModelPlaceholder: "模型 ID",
effort: "推理强度",
providerDefault: "Provider 默认",
loadingEffort: "加载推理强度中...",
effortLoadFailed: "推理强度加载失败",
effortEnabled: "启用",
effortDisabled: "禁用",
customEffortPlaceholder: "输入推理强度",
effortUnsupported: "此模型不支持设置推理强度",
configNameEmpty: "配置名称不能为空",
configNameExists: "配置名称 '{name}' 已存在",
+14
View File
@@ -1356,6 +1356,7 @@ export default withEnglishFallback({
stopping: "正在停止……",
close: "關閉",
cancel: "取消",
confirm: "確定",
save: "保存",
clear: "清空",
copy: "複製",
@@ -1541,10 +1542,23 @@ export default withEnglishFallback({
browseModels: "瀏覽模型",
refreshModels: "重新整理模型",
loadingModels: "載入模型中……",
modelLoadFailed: "模型載入失敗",
retry: "重試",
searchModels: "搜尋模型",
noModels: "尚未載入模型",
noModelMatch: "沒有相符的模型",
selectModel: "選擇模型",
noModel: "尚未設定模型",
manualModel: "手動輸入模型 ID",
manualModelPlaceholder: "模型 ID",
effort: "推理強度",
providerDefault: "Provider 預設",
loadingEffort: "正在載入推理強度……",
effortLoadFailed: "推理強度載入失敗",
effortEnabled: "啟用",
effortDisabled: "停用",
customEffortPlaceholder: "輸入推理強度",
effortUnsupported: "此模型不支援設定推理強度",
configNameEmpty: "設定名稱不能為空",
configNameExists: "設定名稱 '{name}' 已存在",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import type { AiConfig } from "@/types/ai";
function config(overrides: Partial<AiConfig> = {}): AiConfig {
@@ -39,16 +39,3 @@ describe("isAiConfigModelCandidate", () => {
).toBe(true);
});
});
describe("getAiConfigModelIds", () => {
it("includes the default model once while preserving configured model order", () => {
expect(
getAiConfigModelIds(
config({
model: "gpt-default",
models: [{ name: "gpt-fast" }, { name: "gpt-default" }, { name: "gpt-default" }],
}),
),
).toEqual(["gpt-fast", "gpt-default"]);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { effortPreferenceUpdateForCapability, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
import { normalizeAiConfig } from "@/stores/settingsStore";
import type { AiEffortCapability } from "@/types/ai";
const enumCapability: AiEffortCapability = {
kind: "enum",
options: [
{ id: "low", label: "Low", selection: { kind: "enum", value: "low" } },
{ id: "high", label: "High", selection: { kind: "enum", value: "high" } },
],
default: { kind: "enum", value: "low" },
source: "localCli",
};
describe("runtimeEffortFromPreference", () => {
it("leaves runtime effort absent so legacy effort settings remain available", () => {
const config = normalizeAiConfig({
provider: "codex-cli",
enableThinking: false,
reasoningLevel: "high",
runtimeEffort: runtimeEffortFromPreference(null),
});
expect(config.runtimeEffort).toBeUndefined();
expect(config.enableThinking).toBe(false);
expect(config.reasoningLevel).toBe("high");
expect(JSON.parse(JSON.stringify(config))).not.toHaveProperty("runtimeEffort");
});
it("preserves an explicitly selected provider default", () => {
expect(runtimeEffortFromPreference({ kind: "providerDefault" })).toEqual({ kind: "providerDefault" });
});
});
describe("effortPreferenceUpdateForCapability", () => {
it("does not create a preference while capability data loads", () => {
expect(effortPreferenceUpdateForCapability(enumCapability, null)).toBeUndefined();
});
it("preserves existing correction behavior for an unsupported explicit preference", () => {
expect(effortPreferenceUpdateForCapability(enumCapability, { kind: "enum", value: "future" })).toEqual({ kind: "enum", value: "low" });
});
});
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import type { AiModelInfo } from "@/lib/backend/tauri";
describe("normalizeAiModelEffortLevels", () => {
it("preserves the CLI order while removing duplicates and unknown levels", () => {
expect(normalizeAiModelEffortLevels(["low", "high", "xhigh", "high", "future", null, "max"])).toEqual(["low", "high", "xhigh", "max"]);
});
it("returns no configurable levels when metadata is unavailable", () => {
expect(normalizeAiModelEffortLevels(undefined)).toEqual([]);
});
});
describe("normalizeClaudeCodeReasoningLevel", () => {
const model: AiModelInfo = {
id: "claude-sonnet-5",
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
it("keeps an effort level reported by the selected model", () => {
expect(normalizeClaudeCodeReasoningLevel("xhigh", model)).toBe("xhigh");
});
it("falls back to default for unsupported or missing model metadata", () => {
expect(normalizeClaudeCodeReasoningLevel("minimal", model)).toBe("default");
expect(normalizeClaudeCodeReasoningLevel("max", { id: "default" })).toBe("default");
});
});
@@ -2,16 +2,8 @@ import type { AiConfig } from "@/types/ai";
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli"]);
export function getAiConfigModelIds(config: Pick<AiConfig, "model" | "models">): string[] {
const configuredModels = [...new Set(config.models?.map((model) => model.name) ?? [])];
return config.model && !configuredModels.includes(config.model) ? [config.model, ...configuredModels] : configuredModels;
}
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.
if (CLI_PROVIDERS.has(config.provider)) return true;
if (!config.endpoint?.trim() || (requiresApiKey && !config.apiKey?.trim())) return false;
// A discovered model list is sufficient even before the user chooses a default model.
return getAiConfigModelIds(config).some((model) => !!model.trim());
return !!config.endpoint?.trim() && (!requiresApiKey || !!config.apiKey?.trim());
}
@@ -0,0 +1,32 @@
import type { AiEffortCapability, AiEffortSelection } from "@/types/ai";
export function runtimeEffortFromPreference(selection: AiEffortSelection | null): AiEffortSelection | undefined {
return selection ?? undefined;
}
export function effortSelectionEquals(left: AiEffortSelection | null, right: AiEffortSelection): boolean {
return !!left && left.kind === right.kind && ("value" in left ? left.value === ("value" in right ? right.value : undefined) : !("value" in right));
}
function effortSelectionSupported(capability: AiEffortCapability, selection: AiEffortSelection): boolean {
if (selection.kind === "providerDefault") return true;
if (capability.kind === "enum") {
return capability.options.some((option) => effortSelectionEquals(option.selection, selection));
}
if (capability.kind === "integer") {
if (selection.kind === "integer") {
const isSteppedValue = selection.value >= capability.min && selection.value <= capability.max && (selection.value - capability.min) % capability.step === 0;
return isSteppedValue || !!capability.specialValues?.some((option) => effortSelectionEquals(option.selection, selection));
}
return !!capability.specialValues?.some((option) => effortSelectionEquals(option.selection, selection));
}
if (capability.kind === "boolean") return selection.kind === "boolean" || selection.kind === "disabled";
if (capability.kind === "freeText") return selection.kind === "text";
return false;
}
export function effortPreferenceUpdateForCapability(capability: AiEffortCapability, selection: AiEffortSelection | null): AiEffortSelection | null | undefined {
if (!selection || effortSelectionSupported(capability, selection)) return undefined;
if (capability.kind === "unsupported" || capability.kind === "freeText") return null;
return capability.default;
}
-28
View File
@@ -1,28 +0,0 @@
import type { AiEffortLevel, AiReasoningLevel } from "@/types/ai";
interface AiModelEffortMetadata {
supportedEffortLevels?: unknown;
}
const AI_EFFORT_LEVELS = new Set<AiEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
export function normalizeAiModelEffortLevels(value: unknown): AiEffortLevel[] {
if (!Array.isArray(value)) return [];
const seen = new Set<AiEffortLevel>();
const levels: AiEffortLevel[] = [];
for (const level of value) {
if (typeof level !== "string" || !AI_EFFORT_LEVELS.has(level as AiEffortLevel)) continue;
const normalized = level as AiEffortLevel;
if (seen.has(normalized)) continue;
seen.add(normalized);
levels.push(normalized);
}
return levels;
}
export function normalizeClaudeCodeReasoningLevel(reasoningLevel: AiReasoningLevel | undefined, model: AiModelEffortMetadata | undefined): AiReasoningLevel {
if (!reasoningLevel || reasoningLevel === "default") return "default";
const supported = normalizeAiModelEffortLevels(model?.supportedEffortLevels);
return supported.includes(reasoningLevel as AiEffortLevel) ? reasoningLevel : "default";
}
+3
View File
@@ -251,6 +251,9 @@ export const aiAgentStream = forward("aiAgentStream");
export const aiCancelStream = forward("aiCancelStream");
export const aiTestConnection = forward("aiTestConnection");
export const aiListModels = forward("aiListModels");
export const aiResolveModelEffort = forward("aiResolveModelEffort");
export const saveAiChatSelection = forward("saveAiChatSelection");
export const loadAiChatSelection = forward("loadAiChatSelection");
export const saveAiConfig = forward("saveAiConfig");
export const loadAiConfig = forward("loadAiConfig");
export const saveAiConfigs = forward("saveAiConfigs");
+13
View File
@@ -48,6 +48,7 @@ import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
import type { AiChatSelectionState, AiEffortCapability } from "@/types/ai";
import type {
AgentDriverInfo,
AiCompletionRequest,
@@ -1136,6 +1137,18 @@ export async function aiListModels(config: AiConfig): Promise<AiModelInfo[]> {
return post("/api/ai/models", { config });
}
export async function aiResolveModelEffort(config: AiConfig, modelId: string): Promise<AiEffortCapability> {
return post("/api/ai/model-effort", { config, modelId });
}
export async function saveAiChatSelection(selection: AiChatSelectionState): Promise<void> {
return post("/api/ai/chat-selection", { selection });
}
export async function loadAiChatSelection(): Promise<AiChatSelectionState | null> {
return get("/api/ai/chat-selection");
}
export type { AgentEvent } from "@/lib/backend/tauri";
function isAgentEvent(v: unknown): v is import("@/lib/backend/tauri").AgentEvent {
+14 -1
View File
@@ -47,7 +47,7 @@ import type {
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiConfigItem, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { AiChatSelectionState, AiConfig, AiConfigItem, AiEffortCapability, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import type {
@@ -358,6 +358,7 @@ export interface AiModelInfo {
id: string;
displayName?: string;
supportedEffortLevels?: AiEffortLevel[];
effortCapability?: AiEffortCapability;
}
export async function aiComplete(request: AiCompletionRequest): Promise<string> {
@@ -445,6 +446,18 @@ export async function aiListModels(config: AiConfig): Promise<AiModelInfo[]> {
return invoke("ai_list_models", { config });
}
export async function aiResolveModelEffort(config: AiConfig, modelId: string): Promise<AiEffortCapability> {
return invoke("ai_resolve_model_effort", { config, modelId });
}
export async function saveAiChatSelection(selection: AiChatSelectionState): Promise<void> {
return invoke("save_ai_chat_selection", { selection });
}
export async function loadAiChatSelection(): Promise<AiChatSelectionState | null> {
return invoke("load_ai_chat_selection");
}
export async function aiCancelStream(sessionId: string): Promise<boolean> {
return invoke("ai_cancel_stream", { sessionId });
}
+146 -6
View File
@@ -326,7 +326,10 @@ describe("settingsStore AI API key normalization", () => {
it("trims API keys before persisting new configurations", async () => {
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ saveAiConfigItem }));
vi.doMock("@/lib/backend/api", () => ({
saveAiConfigItem,
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
@@ -432,6 +435,8 @@ describe("settingsStore activeModel lifecycle", () => {
loadAiConfigs: vi.fn().mockResolvedValue([]),
loadAiConfig: vi.fn().mockResolvedValue(null),
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
@@ -451,13 +456,15 @@ describe("settingsStore activeModel lifecycle", () => {
expect(store.aiConfigs[1].isDefault).toBe(false);
});
it("setDefaultAiConfig(id) on success points activeModel to the new default config", async () => {
it("setDefaultAiConfig(id) changes the fallback config without replacing the active model", async () => {
const setDefaultAiConfig = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({
loadAiConfigs: vi.fn().mockResolvedValue([]),
loadAiConfig: vi.fn().mockResolvedValue(null),
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
setDefaultAiConfig,
}));
@@ -467,16 +474,15 @@ describe("settingsStore activeModel lifecycle", () => {
store.aiConfigs = [makeTestConfig({ id: "c1", model: "model-a", isDefault: true }), makeTestConfig({ id: "c2", model: "model-b", isDefault: false })];
store.isAiConfigLoaded = true;
// 先手动切到非默认的配置
store.updateActiveModel({ configId: "c2", modelId: "model-b" });
expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" });
store.updateActiveModel({ configId: "c1", modelId: "model-a" });
expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" });
await store.setDefaultAiConfig("c2");
expect(setDefaultAiConfig).toHaveBeenCalledWith("c2");
expect(store.aiConfigs[0].isDefault).toBe(false);
expect(store.aiConfigs[1].isDefault).toBe(true);
expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" });
expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" });
});
it("setDefaultAiConfig does not mutate state when backend call fails", async () => {
@@ -487,6 +493,8 @@ describe("settingsStore activeModel lifecycle", () => {
loadAiConfigs: vi.fn().mockResolvedValue([]),
loadAiConfig: vi.fn().mockResolvedValue(null),
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
setDefaultAiConfig,
}));
@@ -511,6 +519,8 @@ describe("settingsStore activeModel lifecycle", () => {
loadAiConfigs: vi.fn().mockResolvedValue([]),
loadAiConfig: vi.fn().mockResolvedValue(null),
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
@@ -525,6 +535,8 @@ describe("settingsStore activeModel lifecycle", () => {
vi.doMock("@/lib/backend/api", () => ({
loadAiConfigs: vi.fn().mockResolvedValue(configs),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
@@ -533,4 +545,132 @@ describe("settingsStore activeModel lifecycle", () => {
await store.reloadAiConfigs();
expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" });
});
it("restores the locally persisted model and per-model effort independently of legacy config fields", async () => {
const configs = [makeTestConfig({ id: "c1", model: "", isDefault: true })];
vi.doMock("@/lib/backend/api", () => ({
loadAiConfigs: vi.fn().mockResolvedValue(configs),
loadAiChatSelection: vi.fn().mockResolvedValue({
version: 1,
active: { configId: "c1", modelId: "runtime-model" },
effortPreferences: [{ configId: "c1", modelId: "runtime-model", selection: { kind: "enum", value: "high" } }],
}),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initAiConfigs();
expect(store.activeModel).toEqual({ configId: "c1", modelId: "runtime-model" });
expect(store.activeEffort).toEqual({ kind: "enum", value: "high" });
});
it("does not invent an active model when the first saved provider has no legacy model", async () => {
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({
saveAiConfigItem: vi.fn().mockResolvedValue(undefined),
saveAiChatSelection,
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.createAiConfig(makeTestConfig({ id: "c1", model: "", isDefault: true }));
expect(store.activeModel).toBeNull();
expect(saveAiChatSelection).not.toHaveBeenCalled();
});
it("clears the active model and effort when an existing config changes provider", async () => {
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({
saveAiConfigItem,
saveAiChatSelection,
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
store.aiConfigs = [makeTestConfig({ id: "c1", provider: "openai", model: "" })];
store.updateActiveModel({ configId: "c1", modelId: "gpt-5" });
store.updateActiveEffort({ kind: "enum", value: "high" });
await store.updateAiConfigItem("c1", { provider: "gemini" });
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenLastCalledWith({ version: 1, active: undefined, effortPreferences: [] }));
expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", provider: "gemini" }));
expect(store.activeModel).toBeNull();
expect(store.activeEffort).toBeNull();
});
it("preserves the active model and effort when connection details change within the same provider", async () => {
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({
saveAiConfigItem,
saveAiChatSelection,
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
store.aiConfigs = [makeTestConfig({ id: "c1", provider: "openai", model: "" })];
store.updateActiveModel({ configId: "c1", modelId: "gpt-5" });
store.updateActiveEffort({ kind: "enum", value: "high" });
await store.updateAiConfigItem("c1", { endpoint: "https://gateway.example/v1" });
expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", endpoint: "https://gateway.example/v1" }));
expect(store.activeModel).toEqual({ configId: "c1", modelId: "gpt-5" });
expect(store.activeEffort).toEqual({ kind: "enum", value: "high" });
});
it("serializes rapid model and effort persistence without allowing an older snapshot to win", async () => {
let releaseFirstSave!: () => void;
const firstSave = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const saveAiChatSelection = vi
.fn()
.mockImplementationOnce(() => firstSave)
.mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({
saveAiChatSelection,
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
store.updateActiveModel({ configId: "c1", modelId: "model-a" });
store.updateActiveEffort({ kind: "enum", value: "high" });
expect(saveAiChatSelection).toHaveBeenCalledTimes(1);
releaseFirstSave();
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenCalledTimes(2));
expect(saveAiChatSelection.mock.calls[1][0]).toEqual({
version: 1,
active: { configId: "c1", modelId: "model-a" },
effortPreferences: [{ configId: "c1", modelId: "model-a", selection: { kind: "enum", value: "high" } }],
});
});
it("clears stale in-memory AI configs and selections when a reload returns no configs", async () => {
vi.doMock("@/lib/backend/api", () => ({
loadAiConfigs: vi.fn().mockResolvedValue([]),
loadAiConfig: vi.fn().mockResolvedValue(null),
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
loadAiChatSelection: vi.fn().mockResolvedValue(null),
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
}));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
store.aiConfigs = [makeTestConfig({ id: "stale", model: "stale-model", isDefault: true })];
store.activeModel = { configId: "stale", modelId: "stale-model" };
store.isAiConfigLoaded = false;
await store.reloadAiConfigs();
expect(store.aiConfigs).toEqual([]);
expect(store.activeModel).toBeNull();
});
});
+82 -16
View File
@@ -17,9 +17,9 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai";
import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection, AiModelEffortPreference } from "@/types/ai";
export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem };
export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection };
export interface DesktopSettings {
show_tray_icon: boolean;
@@ -980,6 +980,7 @@ export const useSettingsStore = defineStore("settings", () => {
const settingsPageActive = ref(false);
const settingsNavigationRequest = ref<SettingsNavigationRequest | null>(null);
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
const effortPreferences = ref<AiModelEffortPreference[]>([]);
const isAiConfigLoaded = ref(false);
const aiConfigs = ref<AiConfigItem[]>([]);
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
@@ -987,6 +988,8 @@ export const useSettingsStore = defineStore("settings", () => {
const isDesktopSettingsLoaded = ref(false);
const isMcpGlobalPolicyLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
let pendingAiChatSelection: AiChatSelectionState | null = null;
let aiChatSelectionSaveRunning = false;
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
@@ -1094,14 +1097,21 @@ export const useSettingsStore = defineStore("settings", () => {
aiConfigs.value = newConfigs.map(normalizeAiConfigItem);
} else {
// 迁移旧格式
aiConfigs.value = [];
await migrateToMultiConfig();
}
// 重置 activeModel 到默认配置是有意行为——activeModel 是本次运行 (run-scoped) 的末次使用选择,
// 应用启动和配置同步下载 (reloadAiConfigs) 两条路径均需丢弃会话内手动切换的模型、回到默认。
const defaultConfig = aiConfigs.value.find((c) => c.isDefault) || aiConfigs.value[0];
if (defaultConfig) {
activeModel.value = { configId: defaultConfig.id, modelId: defaultConfig.model };
const savedSelection = await api.loadAiChatSelection().catch(() => null);
effortPreferences.value = (savedSelection?.effortPreferences ?? []).filter((preference) => aiConfigs.value.some((config) => config.id === preference.configId));
const savedActive = savedSelection?.active;
const savedConfig = savedActive ? aiConfigs.value.find((config) => config.id === savedActive.configId) : undefined;
if (savedConfig && savedActive?.modelId.trim()) {
activeModel.value = { configId: savedConfig.id, modelId: savedActive.modelId.trim() };
} else {
const fallback = aiConfigs.value.find((config) => config.isDefault) || aiConfigs.value[0];
activeModel.value = fallback?.model.trim() ? { configId: fallback.id, modelId: fallback.model.trim() } : null;
if (activeModel.value) persistAiChatSelection();
}
isAiConfigLoaded.value = true;
@@ -1110,7 +1120,6 @@ export const useSettingsStore = defineStore("settings", () => {
async function reloadAiConfigs(): Promise<void> {
isAiConfigLoaded.value = false;
await initAiConfigs();
if (aiConfigs.value.length === 0) activeModel.value = null;
}
async function migrateToMultiConfig(): Promise<void> {
@@ -1153,23 +1162,36 @@ export const useSettingsStore = defineStore("settings", () => {
const normalized = normalizeAiConfigItem(config);
await api.saveAiConfigItem(normalized);
aiConfigs.value.push(normalized);
if (aiConfigs.value.length === 1) {
if (aiConfigs.value.length === 1 && normalized.model.trim()) {
activeModel.value = { configId: normalized.id, modelId: normalized.model };
persistAiChatSelection();
}
}
async function updateAiConfigItem(id: string, config: Partial<AiConfigItem>): Promise<void> {
const index = aiConfigs.value.findIndex((c) => c.id === id);
if (index !== -1) {
const updated = normalizeAiConfigItem({ ...aiConfigs.value[index], ...config });
const previous = aiConfigs.value[index];
const updated = normalizeAiConfigItem({ ...previous, ...config });
await api.saveAiConfigItem(updated);
aiConfigs.value[index] = updated;
if (previous.provider !== updated.provider) {
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== id);
if (activeModel.value?.configId === id) activeModel.value = null;
persistAiChatSelection();
}
}
}
async function deleteAiConfig(id: string): Promise<void> {
await api.deleteAiConfig(id);
aiConfigs.value = aiConfigs.value.filter((c) => c.id !== id);
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== id);
if (activeModel.value?.configId === id) {
const fallback = aiConfigs.value.find((config) => config.isDefault) || aiConfigs.value[0];
activeModel.value = fallback?.model.trim() ? { configId: fallback.id, modelId: fallback.model.trim() } : null;
}
persistAiChatSelection();
}
async function setDefaultAiConfig(id: string): Promise<void> {
@@ -1177,15 +1199,57 @@ export const useSettingsStore = defineStore("settings", () => {
aiConfigs.value.forEach((c) => {
c.isDefault = c.id === id;
});
const config = aiConfigs.value.find((c) => c.id === id);
if (config) {
// 修改默认配置时丢弃用户手动选择的模型,回到新默认——放在 await 之后确保后端持久化成功才执行
activeModel.value = { configId: config.id, modelId: config.model };
}
}
function updateActiveModel(model: { configId: string; modelId: string }) {
activeModel.value = model;
activeModel.value = { configId: model.configId, modelId: model.modelId.trim() };
persistAiChatSelection();
}
const activeEffort = computed<AiEffortSelection | null>(() => {
const active = activeModel.value;
if (!active) return null;
return effortPreferences.value.find((preference) => preference.configId === active.configId && preference.modelId === active.modelId)?.selection ?? null;
});
function updateActiveEffort(selection: AiEffortSelection | null) {
const active = activeModel.value;
if (!active) return;
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== active.configId || preference.modelId !== active.modelId);
if (selection) {
effortPreferences.value.push({
configId: active.configId,
modelId: active.modelId,
selection,
});
}
persistAiChatSelection();
}
function persistAiChatSelection() {
pendingAiChatSelection = {
version: 1,
active: activeModel.value ? { ...activeModel.value } : undefined,
effortPreferences: effortPreferences.value.map((preference) => ({
...preference,
selection: { ...preference.selection },
})),
};
if (!aiChatSelectionSaveRunning) void flushAiChatSelection();
}
async function flushAiChatSelection() {
aiChatSelectionSaveRunning = true;
try {
while (pendingAiChatSelection) {
const selection = pendingAiChatSelection;
pendingAiChatSelection = null;
await api.saveAiChatSelection(selection).catch(() => {});
}
} finally {
aiChatSelectionSaveRunning = false;
if (pendingAiChatSelection) void flushAiChatSelection();
}
}
const isConfigured = computed((): boolean => {
@@ -1352,6 +1416,7 @@ export const useSettingsStore = defineStore("settings", () => {
requestSettingsNavigation,
clearSettingsNavigationRequest,
activeModel,
activeEffort,
isAiConfigLoaded,
aiConfigs,
initAiConfigs,
@@ -1362,6 +1427,7 @@ export const useSettingsStore = defineStore("settings", () => {
deleteAiConfig,
setDefaultAiConfig,
updateActiveModel,
updateActiveEffort,
isConfigured,
isEditorSettingsLoaded,
editorSettings,
+35
View File
@@ -3,6 +3,23 @@ export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
export type AiReasoningLevel = "default" | "minimal" | AiEffortLevel;
export type AiCapabilitySource = "providerApi" | "localCli" | "officialRegistry" | "custom";
export type AiEffortSelection = { kind: "providerDefault" } | { kind: "disabled" } | { kind: "enum"; value: string } | { kind: "integer"; value: number } | { kind: "boolean"; value: boolean } | { kind: "text"; value: string };
export interface AiEffortOption {
id: string;
label: string;
description?: string;
selection: AiEffortSelection;
}
export type AiEffortCapability =
| { kind: "enum"; options: AiEffortOption[]; default: AiEffortSelection; source: AiCapabilitySource }
| { kind: "integer"; min: number; max: number; step: number; default: AiEffortSelection; specialValues?: AiEffortOption[]; source: AiCapabilitySource }
| { kind: "boolean"; default: AiEffortSelection; source: AiCapabilitySource }
| { kind: "freeText"; placeholder?: string; source: AiCapabilitySource }
| { kind: "unsupported" };
export interface AiConfiguredModel {
name: string;
@@ -27,6 +44,7 @@ export interface AiConfig {
codexCliEnv?: Record<string, string>;
claudeCodeCliPath?: string | null;
claudeCodeCliEnv?: Record<string, string>;
runtimeEffort?: AiEffortSelection | null;
}
export interface AiTestConnectionResult {
@@ -42,3 +60,20 @@ export interface AiConfigItem extends AiConfig {
name: string;
isDefault?: boolean;
}
export interface AiActiveModelSelection {
configId: string;
modelId: string;
}
export interface AiModelEffortPreference {
configId: string;
modelId: string;
selection: AiEffortSelection;
}
export interface AiChatSelectionState {
version: number;
active?: AiActiveModelSelection;
effortPreferences: AiModelEffortPreference[];
}