feat(ai): unify model discovery and effort handling
This commit is contained in:
parent
c54b95e274
commit
c7e7b7c9d9
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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à",
|
||||
|
||||
|
|
|
|||
|
|
@ -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}' は既に存在します",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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}' 已存在",
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ pub struct ToolCall {
|
|||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
/// Opaque provider response data required to replay this tool call in a
|
||||
/// follow-up request (for example, Gemini thought signatures).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Result of executing a tool.
|
||||
|
|
|
|||
|
|
@ -84,14 +84,33 @@ pub struct AgentLoopContext {
|
|||
pub max_agent_turns: u32,
|
||||
}
|
||||
|
||||
/// Check if the provider supports function calling / tool use.
|
||||
/// Returns false for providers that are known to lack reliable tool support.
|
||||
fn provider_supports_function_calling(config: &AiConfig) -> bool {
|
||||
match config.provider {
|
||||
// Ollama function calling support varies by model/version; conservative default is false.
|
||||
// Users with capable models can override via openai-compatible with an Ollama endpoint.
|
||||
AiProvider::Ollama => false,
|
||||
_ => true,
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FunctionCallingSupport {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Resolve function calling support for the selected provider/model.
|
||||
///
|
||||
/// Native Ollama capabilities are model-specific. Missing metadata is kept as
|
||||
/// unknown so older servers and compatible proxies can still attempt tools.
|
||||
async fn provider_function_calling_support(config: &AiConfig) -> FunctionCallingSupport {
|
||||
if !matches!(config.provider, AiProvider::Ollama) {
|
||||
return FunctionCallingSupport::Supported;
|
||||
}
|
||||
|
||||
match ai::ollama_selected_model_tool_support(config).await {
|
||||
Ok(Some(true)) => FunctionCallingSupport::Supported,
|
||||
Ok(Some(false)) => FunctionCallingSupport::Unsupported,
|
||||
Ok(None) => FunctionCallingSupport::Unknown,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[agent][ollama] tool capability unavailable for model {}: {error}; attempting tool call",
|
||||
config.model
|
||||
);
|
||||
FunctionCallingSupport::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +119,9 @@ fn provider_supports_function_calling(config: &AiConfig) -> bool {
|
|||
/// The `on_event` callback receives streaming events for the frontend.
|
||||
/// Returns the final accumulated assistant text.
|
||||
///
|
||||
/// If the provider does not support function calling (e.g., Ollama), automatically
|
||||
/// degrades to a text-only completion with schema context injected into the system prompt.
|
||||
/// If the selected model explicitly does not support function calling,
|
||||
/// automatically degrades to a text-only completion with schema context
|
||||
/// injected into the system prompt.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_agent_loop(
|
||||
config: &AiConfig,
|
||||
|
|
@ -149,8 +169,19 @@ pub async fn run_agent_loop(
|
|||
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
|
||||
// Auto-degrade: providers without function calling fall back to text-only completion.
|
||||
if !provider_supports_function_calling(config) {
|
||||
// Auto-degrade only models that explicitly advertise no tool support.
|
||||
// Unknown Ollama capabilities still get a chance to use tools so older
|
||||
// servers and compatible proxies are not disabled provider-wide.
|
||||
let function_calling_support = tokio::select! {
|
||||
support = provider_function_calling_support(config) => support,
|
||||
_ = cancelled.notified() => {
|
||||
let message = "Agent run was cancelled before producing output.".to_string();
|
||||
on_event(AgentEvent::TextDelta { delta: message.clone() });
|
||||
on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
|
||||
return Ok(message);
|
||||
}
|
||||
};
|
||||
if function_calling_support == FunctionCallingSupport::Unsupported {
|
||||
return run_agent_loop_text_only(
|
||||
config,
|
||||
system_prompt,
|
||||
|
|
@ -248,6 +279,29 @@ pub async fn run_agent_loop(
|
|||
stream_result = Some((tool_calls, usage, accumulated_text));
|
||||
break;
|
||||
}
|
||||
Err(err)
|
||||
if turn == 0
|
||||
&& matches!(config.provider, AiProvider::Ollama)
|
||||
&& is_tool_unsupported_error(&err)
|
||||
&& !emitted_any_chunk.load(Ordering::Relaxed) =>
|
||||
{
|
||||
log::debug!(
|
||||
"[agent][ollama] model {} rejected tools before producing output; falling back to text-only mode",
|
||||
config.model
|
||||
);
|
||||
on_event(AgentEvent::TurnEnd { turn });
|
||||
return run_agent_loop_text_only(
|
||||
config,
|
||||
system_prompt,
|
||||
messages,
|
||||
agent_ctx,
|
||||
on_event,
|
||||
cancelled,
|
||||
max_tokens,
|
||||
task_contract.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err)
|
||||
if attempt == 0 && is_context_length_error(&err) && !emitted_any_chunk.load(Ordering::Relaxed) =>
|
||||
{
|
||||
|
|
@ -315,7 +369,12 @@ pub async fn run_agent_loop(
|
|||
tool_call_id: None,
|
||||
tool_calls: collected_tool_calls
|
||||
.iter()
|
||||
.map(|tc| ai::ToolCallRef { id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone() })
|
||||
.map(|tc| ai::ToolCallRef {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
arguments: tc.arguments.clone(),
|
||||
provider_payload: tc.provider_payload.clone(),
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
|
||||
|
|
@ -377,8 +436,12 @@ pub async fn run_agent_loop(
|
|||
let (parallel_indices, sequential_indices): (Vec<usize>, Vec<usize>) = (0..collected_tool_calls.len())
|
||||
.partition(|&i| *tool_parallel_map.get(collected_tool_calls[i].name.as_str()).unwrap_or(&false));
|
||||
|
||||
let make_tc =
|
||||
|tc: &ToolCall| ToolCall { id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone() };
|
||||
let make_tc = |tc: &ToolCall| ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
arguments: tc.arguments.clone(),
|
||||
provider_payload: tc.provider_payload.clone(),
|
||||
};
|
||||
|
||||
// Run parallel group
|
||||
let parallel_futures: Vec<_> = parallel_indices
|
||||
|
|
@ -692,6 +755,24 @@ fn is_context_length_error(error: &str) -> bool {
|
|||
.any(|marker| lower.contains(marker))
|
||||
}
|
||||
|
||||
fn is_tool_unsupported_error(error: &str) -> bool {
|
||||
let lower = error.to_lowercase();
|
||||
let mentions_tool_use = lower.contains("tool") || lower.contains("function call");
|
||||
let rejects_capability = [
|
||||
"does not support",
|
||||
"doesn't support",
|
||||
"not supported",
|
||||
"unsupported",
|
||||
"unknown field",
|
||||
"unknown parameter",
|
||||
"unrecognized field",
|
||||
"unrecognized parameter",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker));
|
||||
mentions_tool_use && rejects_capability
|
||||
}
|
||||
|
||||
/// Text-only fallback for providers that don't support function calling.
|
||||
///
|
||||
/// Injects database schema context into the system prompt so the LLM can still
|
||||
|
|
@ -836,6 +917,11 @@ fn estimate_message_tokens(message: &AiMessage) -> u32 {
|
|||
if let Ok(args) = serde_json::to_string(&tool_call.arguments) {
|
||||
tokens += estimate_text_tokens(&args);
|
||||
}
|
||||
if let Some(provider_payload) = &tool_call.provider_payload {
|
||||
if let Ok(payload) = serde_json::to_string(provider_payload) {
|
||||
tokens += estimate_text_tokens(&payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens
|
||||
|
|
@ -1201,6 +1287,32 @@ mod tests {
|
|||
assert_eq!(clamp_max_agent_turns(u32::MAX), MAX_MAX_AGENT_TURNS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_explicit_tool_rejection_errors() {
|
||||
for error in [
|
||||
"model does not support tools",
|
||||
"tool use is not supported by this model",
|
||||
"unsupported parameter: tools",
|
||||
"unknown field `tools`",
|
||||
"function calling is not supported",
|
||||
] {
|
||||
assert!(is_tool_unsupported_error(error), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_hide_unrelated_ollama_errors_as_tool_rejections() {
|
||||
for error in [
|
||||
"connection refused",
|
||||
"model not found",
|
||||
"context length exceeded",
|
||||
"invalid tool arguments returned by model",
|
||||
"request timed out",
|
||||
] {
|
||||
assert!(!is_tool_unsupported_error(error), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_contract(user_request: &str, mode: &str) -> AiTaskContract {
|
||||
AiTaskContract {
|
||||
action: Some("generate".to_string()),
|
||||
|
|
|
|||
|
|
@ -662,6 +662,7 @@ async fn execute_get_sample_data(
|
|||
id: tool_call.id.clone(),
|
||||
name: "execute_query".to_string(),
|
||||
arguments: serde_json::json!({ "sql": sql, "limit": limit }),
|
||||
provider_payload: None,
|
||||
};
|
||||
execute_execute_query(&synthetic_call, state, connection_id, database, db_type, AgentSqlPermissions::default())
|
||||
.await
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
|||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiConfig, AiEffortLevel, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai::{AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortLevel, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai_cli_agent::{
|
||||
build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos, parse_cli_jsonl_event,
|
||||
run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
|
||||
|
|
@ -284,9 +284,13 @@ fn build_claude_code_command_with_mcp_arg(
|
|||
args.push("--model".to_string());
|
||||
args.push(model.to_string());
|
||||
}
|
||||
if let Some(effort) = config.reasoning_level.as_claude_code_effort() {
|
||||
let effort = match config.runtime_effort.as_ref() {
|
||||
Some(effort) => effort.cli_value(),
|
||||
None => config.reasoning_level.as_claude_code_effort().map(ToString::to_string),
|
||||
};
|
||||
if let Some(effort) = effort {
|
||||
args.push("--effort".to_string());
|
||||
args.push(effort.to_string());
|
||||
args.push(effort);
|
||||
}
|
||||
|
||||
ClaudeCodeCommandSpec { program: claude_code_program(config), args }
|
||||
|
|
@ -387,7 +391,17 @@ fn parse_claude_code_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
|
|||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let mut info = AiModelInfo::new(id, display_name);
|
||||
info.supported_effort_levels = parse_claude_code_effort_levels(model);
|
||||
let effort_levels = parse_claude_code_effort_level_strings(model);
|
||||
info.supported_effort_levels =
|
||||
effort_levels.iter().filter_map(|level| level.parse::<AiEffortLevel>().ok()).collect();
|
||||
info.effort_capability =
|
||||
if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool)
|
||||
== Some(false)
|
||||
{
|
||||
Some(AiEffortCapability::Unsupported)
|
||||
} else {
|
||||
crate::ai_effort::dynamic_enum_capability(effort_levels, AiCapabilitySource::LocalCli)
|
||||
};
|
||||
result.push(info);
|
||||
}
|
||||
|
||||
|
|
@ -403,7 +417,7 @@ fn parse_claude_code_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
|
|||
None
|
||||
}
|
||||
|
||||
fn parse_claude_code_effort_levels(model: &Value) -> Vec<AiEffortLevel> {
|
||||
fn parse_claude_code_effort_level_strings(model: &Value) -> Vec<String> {
|
||||
if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool) == Some(false) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -417,8 +431,10 @@ fn parse_claude_code_effort_levels(model: &Value) -> Vec<AiEffortLevel> {
|
|||
levels
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(|level| level.parse::<AiEffortLevel>().ok())
|
||||
.filter(|level| seen.insert(*level))
|
||||
.map(str::trim)
|
||||
.filter(|level| !level.is_empty())
|
||||
.filter(|level| seen.insert((*level).to_string()))
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -532,7 +548,10 @@ mod tests {
|
|||
#[cfg(unix)]
|
||||
use super::{list_claude_code_models, run_claude_code_agent};
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortLevel, AiModelInfo, AiProvider, AiReasoningLevel};
|
||||
use crate::ai::{
|
||||
AiApiStyle, AiAuthMethod, AiConfig, AiEffortCapability, AiEffortLevel, AiEffortSelection, AiProvider,
|
||||
AiReasoningLevel,
|
||||
};
|
||||
use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
|
@ -552,6 +571,7 @@ mod tests {
|
|||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
|
|
@ -736,6 +756,21 @@ esac
|
|||
assert!(!spec.args.contains(&"--effort".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_effort_takes_priority_over_legacy_reasoning_level() {
|
||||
let mut config = claude_code_config("sonnet");
|
||||
config.reasoning_level = AiReasoningLevel::Xhigh;
|
||||
config.runtime_effort = Some(AiEffortSelection::ProviderDefault);
|
||||
|
||||
let spec = build_claude_code_command(&config, "hello", &run_options());
|
||||
assert!(!spec.args.contains(&"--effort".to_string()));
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("future".to_string()));
|
||||
let spec = build_claude_code_command(&config, "hello", &run_options());
|
||||
let effort_pos = spec.args.iter().position(|arg| arg == "--effort").unwrap();
|
||||
assert_eq!(spec.args[effort_pos + 1], "future");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_list_matches_supported_aliases() {
|
||||
assert_eq!(model_infos(DEFAULT_CLAUDE_CODE_MODELS), model_infos(&["default", "sonnet", "opus", "fable"]));
|
||||
|
|
@ -755,21 +790,21 @@ esac
|
|||
let models = parse_claude_code_models(stdout).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
models,
|
||||
vec![
|
||||
AiModelInfo::new("default", Some("Default".to_string())),
|
||||
AiModelInfo {
|
||||
id: "claude-sonnet-4-6".to_string(),
|
||||
display_name: Some("Sonnet 4.6".to_string()),
|
||||
supported_effort_levels: vec![
|
||||
AiEffortLevel::Low,
|
||||
AiEffortLevel::Medium,
|
||||
AiEffortLevel::High,
|
||||
AiEffortLevel::Max
|
||||
],
|
||||
},
|
||||
AiModelInfo::new("claude-opus-4-8", Some("Opus 4.8".to_string())),
|
||||
]
|
||||
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
|
||||
["default", "claude-sonnet-4-6", "claude-opus-4-8"]
|
||||
);
|
||||
let sonnet = &models[1];
|
||||
assert_eq!(sonnet.display_name.as_deref(), Some("Sonnet 4.6"));
|
||||
assert_eq!(
|
||||
sonnet.supported_effort_levels,
|
||||
[AiEffortLevel::Low, AiEffortLevel::Medium, AiEffortLevel::High, AiEffortLevel::Max]
|
||||
);
|
||||
let AiEffortCapability::Enum { options, .. } = sonnet.effort_capability.as_ref().unwrap() else {
|
||||
panic!("expected effort enum");
|
||||
};
|
||||
assert_eq!(
|
||||
options.iter().map(|option| option.id.as_str()).collect::<Vec<_>>(),
|
||||
["low", "medium", "high", "max", "future"]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiConfig, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai::{AiCapabilitySource, AiConfig, AiModelInfo, AiTestConnectionResult};
|
||||
use crate::ai_cli_agent::{
|
||||
append_config_overrides, build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env,
|
||||
model_infos, parse_cli_jsonl_event, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec,
|
||||
|
|
@ -417,8 +417,12 @@ pub fn build_codex_exec_command(config: &AiConfig, _prompt: &str, options: &Code
|
|||
"read-only".to_string(),
|
||||
];
|
||||
let mut config_overrides = vec!["features.shell_tool=false".to_string(), "web_search=\"disabled\"".to_string()];
|
||||
if let Some(reasoning_effort) = config.reasoning_level.as_codex_effort() {
|
||||
config_overrides.push(format!("model_reasoning_effort={}", toml_string(reasoning_effort)));
|
||||
let reasoning_effort = match config.runtime_effort.as_ref() {
|
||||
Some(effort) => effort.cli_value(),
|
||||
None => config.reasoning_level.as_codex_effort().map(ToString::to_string),
|
||||
};
|
||||
if let Some(reasoning_effort) = reasoning_effort {
|
||||
config_overrides.push(format!("model_reasoning_effort={}", toml_string(&reasoning_effort)));
|
||||
}
|
||||
append_config_overrides(&mut args, config_overrides.into_iter().chain(codex_mcp_config_overrides(options)));
|
||||
|
||||
|
|
@ -483,7 +487,21 @@ fn parse_codex_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
|
|||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
result.push(AiModelInfo::new(id, display_name));
|
||||
let mut info = AiModelInfo::new(id, display_name);
|
||||
let levels = model
|
||||
.get("supported_reasoning_levels")
|
||||
.or_else(|| model.get("supportedReasoningLevels"))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|level| {
|
||||
level
|
||||
.as_str()
|
||||
.or_else(|| level.get("effort").and_then(Value::as_str))
|
||||
.or_else(|| level.get("value").and_then(Value::as_str))
|
||||
});
|
||||
info.effort_capability = crate::ai_effort::dynamic_enum_capability(levels, AiCapabilitySource::LocalCli);
|
||||
result.push(info);
|
||||
}
|
||||
|
||||
(result.len() > 1).then_some(result)
|
||||
|
|
@ -596,7 +614,7 @@ mod tests {
|
|||
windows_npm_codex_shim_command,
|
||||
};
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel};
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortSelection, AiProvider, AiReasoningLevel};
|
||||
use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec};
|
||||
|
||||
fn codex_config(model: &str) -> AiConfig {
|
||||
|
|
@ -612,6 +630,7 @@ mod tests {
|
|||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
|
|
@ -687,6 +706,21 @@ mod tests {
|
|||
assert!(spec.args.contains(&"model_reasoning_effort=\"high\"".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_effort_takes_priority_over_legacy_reasoning_level() {
|
||||
let mut config = codex_config("default");
|
||||
config.reasoning_level = AiReasoningLevel::High;
|
||||
config.runtime_effort = Some(AiEffortSelection::ProviderDefault);
|
||||
|
||||
let spec = build_codex_exec_command(&config, "hello", &run_options());
|
||||
|
||||
assert!(!spec.args.iter().any(|arg| arg.starts_with("model_reasoning_effort=")));
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("xhigh".to_string()));
|
||||
let spec = build_codex_exec_command(&config, "hello", &run_options());
|
||||
assert!(spec.args.contains(&"model_reasoning_effort=\"xhigh\"".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_like_codex_programs_are_detected() {
|
||||
assert!(is_path_like_program("/opt/homebrew/bin/codex"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,606 @@
|
|||
use crate::ai::{
|
||||
AiApiStyle, AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortOption, AiEffortSelection, AiProvider,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
const OPENAI_REASONING_DOCS: &str = "https://platform.openai.com/docs/guides/reasoning";
|
||||
const GEMINI_THINKING_DOCS: &str = "https://ai.google.dev/gemini-api/docs/thinking";
|
||||
const DEEPSEEK_THINKING_DOCS: &str = "https://api-docs.deepseek.com/guides/thinking_mode";
|
||||
const QWEN_THINKING_DOCS: &str = "https://help.aliyun.com/en/model-studio/deep-thinking";
|
||||
const OLLAMA_THINKING_DOCS: &str = "https://docs.ollama.com/capabilities/thinking";
|
||||
|
||||
pub const EFFORT_REGISTRY_LAST_VERIFIED: &str = "2026-07-26";
|
||||
|
||||
fn option(id: &str, label: &str, selection: AiEffortSelection) -> AiEffortOption {
|
||||
AiEffortOption { id: id.to_string(), label: label.to_string(), description: None, selection }
|
||||
}
|
||||
|
||||
fn enum_capability(values: &[&str], source: AiCapabilitySource) -> AiEffortCapability {
|
||||
let options = values
|
||||
.iter()
|
||||
.map(|value| option(value, &title_case_effort(value), AiEffortSelection::Enum((*value).to_string())))
|
||||
.collect::<Vec<_>>();
|
||||
let default = options.first().map(|option| option.selection.clone()).unwrap_or(AiEffortSelection::ProviderDefault);
|
||||
AiEffortCapability::Enum { options, default, source }
|
||||
}
|
||||
|
||||
pub fn dynamic_enum_capability<I, S>(values: I, source: AiCapabilitySource) -> Option<AiEffortCapability>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut seen = Vec::<String>::new();
|
||||
for value in values {
|
||||
let value = value.as_ref().trim();
|
||||
if value.is_empty() || seen.iter().any(|existing| existing == value) {
|
||||
continue;
|
||||
}
|
||||
seen.push(value.to_string());
|
||||
}
|
||||
(!seen.is_empty()).then(|| {
|
||||
let refs = seen.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
enum_capability(&refs, source)
|
||||
})
|
||||
}
|
||||
|
||||
fn integer_capability(min: i64, max: i64, allow_disabled: bool, source: AiCapabilitySource) -> AiEffortCapability {
|
||||
let mut special_values = vec![option("auto", "Auto", AiEffortSelection::Integer(-1))];
|
||||
if allow_disabled {
|
||||
special_values.push(option("off", "Off", AiEffortSelection::Disabled));
|
||||
}
|
||||
AiEffortCapability::Integer { min, max, step: 1, default: AiEffortSelection::Integer(-1), special_values, source }
|
||||
}
|
||||
|
||||
fn boolean_capability(source: AiCapabilitySource) -> AiEffortCapability {
|
||||
AiEffortCapability::Boolean { default: AiEffortSelection::ProviderDefault, source }
|
||||
}
|
||||
|
||||
fn normalized_model_id(model_id: &str) -> String {
|
||||
model_id.trim().trim_start_matches("models/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn matches_family(model: &str, family: &str) -> bool {
|
||||
model == family
|
||||
|| model
|
||||
.strip_prefix(family)
|
||||
.is_some_and(|suffix| suffix.starts_with('-') || suffix.starts_with(':') || suffix.starts_with('@'))
|
||||
}
|
||||
|
||||
pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiEffortCapability> {
|
||||
let model = normalized_model_id(model_id);
|
||||
let source = AiCapabilitySource::OfficialRegistry;
|
||||
|
||||
match config.provider {
|
||||
AiProvider::Openai => openai_capability(&model, source),
|
||||
AiProvider::Gemini => gemini_capability(&model, source),
|
||||
AiProvider::Deepseek => deepseek_capability(&model, source),
|
||||
AiProvider::Qwen => qwen_capability(&model, source),
|
||||
AiProvider::Ollama => ollama_capability(&model, source),
|
||||
AiProvider::OpenaiCompatible | AiProvider::Custom => {
|
||||
Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom })
|
||||
}
|
||||
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_capability(model: &str, source: AiCapabilitySource) -> Option<AiEffortCapability> {
|
||||
if matches_family(model, "gpt-5-pro") {
|
||||
return Some(enum_capability(&["high"], source));
|
||||
}
|
||||
if matches_family(model, "gpt-5.6") {
|
||||
return Some(enum_capability(&["none", "low", "medium", "high", "xhigh", "max"], source));
|
||||
}
|
||||
if matches_family(model, "gpt-5.1") {
|
||||
return Some(enum_capability(&["none", "low", "medium", "high"], source));
|
||||
}
|
||||
if matches_family(model, "gpt-5") || matches_family(model, "gpt-5.2") || matches_family(model, "gpt-5.4") {
|
||||
return Some(enum_capability(&["minimal", "low", "medium", "high", "xhigh"], source));
|
||||
}
|
||||
if ["o1", "o3", "o3-mini", "o4-mini"].iter().any(|family| matches_family(model, family)) {
|
||||
return Some(enum_capability(&["low", "medium", "high"], source));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn gemini_capability(model: &str, source: AiCapabilitySource) -> Option<AiEffortCapability> {
|
||||
if matches_family(model, "gemini-2.5-pro") {
|
||||
return Some(integer_capability(128, 32_768, false, source));
|
||||
}
|
||||
if matches_family(model, "gemini-2.5-flash-lite") {
|
||||
return Some(integer_capability(512, 24_576, true, source));
|
||||
}
|
||||
if matches_family(model, "gemini-2.5-flash")
|
||||
|| matches_family(model, "robotics-er-1.6-preview")
|
||||
|| model.starts_with("gemini-2.5-flash-live")
|
||||
{
|
||||
return Some(integer_capability(0, 24_576, true, source));
|
||||
}
|
||||
if model.starts_with("gemini-3.6-flash")
|
||||
|| model.starts_with("gemini-3.5-flash")
|
||||
|| model.starts_with("gemini-3-flash")
|
||||
{
|
||||
return Some(enum_capability(&["minimal", "low", "medium", "high"], source));
|
||||
}
|
||||
if model.starts_with("gemini-3.1-pro") {
|
||||
return Some(enum_capability(&["low", "medium", "high"], source));
|
||||
}
|
||||
if model.starts_with("gemini-3.1-flash-lite-image") {
|
||||
return Some(enum_capability(&["minimal", "high"], source));
|
||||
}
|
||||
if model.starts_with("gemini-3-pro") {
|
||||
return Some(enum_capability(&["low", "high"], source));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn deepseek_capability(model: &str, source: AiCapabilitySource) -> Option<AiEffortCapability> {
|
||||
if matches_family(model, "deepseek-v4-flash") || matches_family(model, "deepseek-v4-pro") {
|
||||
let mut capability = enum_capability(&["high", "max"], source);
|
||||
if let AiEffortCapability::Enum { options, default, .. } = &mut capability {
|
||||
options.insert(0, option("off", "Off", AiEffortSelection::Disabled));
|
||||
*default = AiEffortSelection::Disabled;
|
||||
}
|
||||
return Some(capability);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn qwen_capability(model: &str, source: AiCapabilitySource) -> Option<AiEffortCapability> {
|
||||
if matches_family(model, "qwen3.8-max-preview") {
|
||||
return Some(enum_capability(&["low", "medium", "xhigh"], source));
|
||||
}
|
||||
if model.starts_with("qwen3") || model.starts_with("qwq") {
|
||||
return Some(boolean_capability(source));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ollama_capability(model: &str, source: AiCapabilitySource) -> Option<AiEffortCapability> {
|
||||
if matches_family(model, "gpt-oss") {
|
||||
return Some(enum_capability(&["low", "medium", "high"], source));
|
||||
}
|
||||
if model.starts_with("deepseek-r1")
|
||||
|| model.starts_with("qwen3")
|
||||
|| model.starts_with("qwq")
|
||||
|| model.starts_with("nemotron")
|
||||
|| model.starts_with("glm-4.7")
|
||||
{
|
||||
return Some(boolean_capability(source));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
|
||||
match provider {
|
||||
AiProvider::Openai => Some(OPENAI_REASONING_DOCS),
|
||||
AiProvider::Gemini => Some(GEMINI_THINKING_DOCS),
|
||||
AiProvider::Deepseek => Some(DEEPSEEK_THINKING_DOCS),
|
||||
AiProvider::Qwen => Some(QWEN_THINKING_DOCS),
|
||||
AiProvider::Ollama => Some(OLLAMA_THINKING_DOCS),
|
||||
AiProvider::Claude
|
||||
| AiProvider::OpenaiCompatible
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::Custom => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
|
||||
let Some(selection) = config.runtime_effort.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if matches!(selection, AiEffortSelection::ProviderDefault) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
return match selection {
|
||||
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
|
||||
_ => Err("Invalid effort selection for dynamic provider".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let capability = static_effort_capability(config, &config.model).unwrap_or(AiEffortCapability::Unsupported);
|
||||
let valid = match capability {
|
||||
AiEffortCapability::Enum { options, .. } => options.iter().any(|option| option.selection == *selection),
|
||||
AiEffortCapability::Integer { min, max, step, special_values, .. } => {
|
||||
special_values.iter().any(|option| option.selection == *selection)
|
||||
|| matches!(selection, AiEffortSelection::Integer(value) if *value >= min && *value <= max && (*value - min) % step == 0)
|
||||
}
|
||||
AiEffortCapability::Boolean { .. } => {
|
||||
matches!(selection, AiEffortSelection::Boolean(_) | AiEffortSelection::Disabled)
|
||||
}
|
||||
AiEffortCapability::FreeText { .. } => {
|
||||
matches!(selection, AiEffortSelection::Text(value) if valid_custom_effort(value))
|
||||
}
|
||||
AiEffortCapability::Unsupported => false,
|
||||
};
|
||||
|
||||
valid.then_some(()).ok_or_else(|| format!("Invalid effort selection for model '{}'", config.model))
|
||||
}
|
||||
|
||||
pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
|
||||
let Some(selection) = config.runtime_effort.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if matches!(selection, AiEffortSelection::ProviderDefault) {
|
||||
return;
|
||||
}
|
||||
let Some(object) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match config.provider {
|
||||
AiProvider::Claude => apply_claude_effort(object, selection),
|
||||
AiProvider::Gemini => apply_gemini_effort(object, &config.model, selection),
|
||||
AiProvider::Deepseek => apply_deepseek_effort(object, selection),
|
||||
AiProvider::Qwen => apply_qwen_effort(object, selection),
|
||||
AiProvider::Ollama => apply_openai_effort(object, &config.api_style, selection),
|
||||
AiProvider::Openai | AiProvider::OpenaiCompatible => apply_openai_effort(object, &config.api_style, selection),
|
||||
AiProvider::Custom => {
|
||||
if config.api_style == AiApiStyle::AnthropicMessages {
|
||||
apply_claude_effort(object, selection);
|
||||
} else {
|
||||
apply_openai_effort(object, &config.api_style, selection);
|
||||
}
|
||||
}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_claude_effort(object: &mut Map<String, Value>, selection: &AiEffortSelection) {
|
||||
if let Some(value) = effort_string(selection) {
|
||||
object.insert("output_config".to_string(), json!({ "effort": value }));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_openai_effort(object: &mut Map<String, Value>, api_style: &AiApiStyle, selection: &AiEffortSelection) {
|
||||
if let Some(value) = effort_string(selection) {
|
||||
if *api_style == AiApiStyle::Responses {
|
||||
object.insert("reasoning".to_string(), json!({ "effort": value }));
|
||||
} else {
|
||||
object.insert("reasoning_effort".to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_gemini_effort(object: &mut Map<String, Value>, model_id: &str, selection: &AiEffortSelection) {
|
||||
let generation_config = object.entry("generationConfig").or_insert_with(|| Value::Object(Map::new()));
|
||||
let Some(generation_config) = generation_config.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let thinking_config = generation_config.entry("thinkingConfig").or_insert_with(|| Value::Object(Map::new()));
|
||||
let Some(thinking_config) = thinking_config.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if normalized_model_id(model_id).starts_with("gemini-2.5")
|
||||
|| normalized_model_id(model_id).starts_with("robotics-er")
|
||||
{
|
||||
let value = match selection {
|
||||
AiEffortSelection::Disabled => Some(0),
|
||||
AiEffortSelection::Integer(value) => Some(*value),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(value) = value {
|
||||
thinking_config.insert("thinkingBudget".to_string(), Value::Number(value.into()));
|
||||
}
|
||||
} else if let Some(value) = effort_string(selection) {
|
||||
thinking_config.insert("thinkingLevel".to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_deepseek_effort(object: &mut Map<String, Value>, selection: &AiEffortSelection) {
|
||||
match selection {
|
||||
AiEffortSelection::Disabled | AiEffortSelection::Boolean(false) => {
|
||||
object.insert("thinking".to_string(), json!({ "type": "disabled" }));
|
||||
}
|
||||
AiEffortSelection::Boolean(true) => {
|
||||
object.insert("thinking".to_string(), json!({ "type": "enabled" }));
|
||||
}
|
||||
_ => {
|
||||
if let Some(value) = effort_string(selection) {
|
||||
object.insert("thinking".to_string(), json!({ "type": "enabled" }));
|
||||
object.insert("reasoning_effort".to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_qwen_effort(object: &mut Map<String, Value>, selection: &AiEffortSelection) {
|
||||
object.remove("reasoning_effort");
|
||||
object.remove("thinking_budget");
|
||||
object.remove("enable_thinking");
|
||||
match selection {
|
||||
AiEffortSelection::Disabled | AiEffortSelection::Boolean(false) => {
|
||||
object.insert("enable_thinking".to_string(), Value::Bool(false));
|
||||
}
|
||||
AiEffortSelection::Boolean(true) => {
|
||||
object.insert("enable_thinking".to_string(), Value::Bool(true));
|
||||
}
|
||||
AiEffortSelection::Integer(value) => {
|
||||
object.insert("thinking_budget".to_string(), Value::Number((*value).into()));
|
||||
}
|
||||
_ => {
|
||||
if let Some(value) = effort_string(selection) {
|
||||
object.insert("reasoning_effort".to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_custom_effort(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value.chars().count() <= 64 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn effort_string(selection: &AiEffortSelection) -> Option<String> {
|
||||
match selection {
|
||||
AiEffortSelection::Enum(value) | AiEffortSelection::Text(value) => {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
AiEffortSelection::Disabled => Some("none".to_string()),
|
||||
AiEffortSelection::Boolean(value) => Some(if *value { "high" } else { "none" }.to_string()),
|
||||
AiEffortSelection::ProviderDefault | AiEffortSelection::Integer(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case_effort(value: &str) -> String {
|
||||
let mut chars = value.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{apply_runtime_effort, dynamic_enum_capability, static_effort_capability, validate_runtime_effort};
|
||||
use crate::ai::{
|
||||
AiApiStyle, AiAuthMethod, AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortSelection, AiProvider,
|
||||
AiReasoningLevel,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn config(provider: AiProvider, model: &str) -> AiConfig {
|
||||
AiConfig {
|
||||
provider,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::ApiKey,
|
||||
endpoint: String::new(),
|
||||
model: model.to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: HashMap::new(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_levels_preserve_unknown_values_and_order() {
|
||||
let capability = dynamic_enum_capability(["low", "ultra", "low", ""], AiCapabilitySource::LocalCli).unwrap();
|
||||
let AiEffortCapability::Enum { options, .. } = capability else {
|
||||
panic!("expected enum capability");
|
||||
};
|
||||
assert_eq!(options.iter().map(|option| option.id.as_str()).collect::<Vec<_>>(), ["low", "ultra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_effort_uses_the_translated_frontend_placeholder() {
|
||||
for provider in [AiProvider::OpenaiCompatible, AiProvider::Custom] {
|
||||
let capability = static_effort_capability(&config(provider, "custom-model"), "custom-model").unwrap();
|
||||
let AiEffortCapability::FreeText { placeholder, .. } = capability else {
|
||||
panic!("expected free-text capability");
|
||||
};
|
||||
assert_eq!(placeholder, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_25_pro_uses_numeric_budget_without_off() {
|
||||
let capability =
|
||||
static_effort_capability(&config(AiProvider::Gemini, "gemini-2.5-pro"), "gemini-2.5-pro").unwrap();
|
||||
let AiEffortCapability::Integer { min, max, special_values, .. } = capability else {
|
||||
panic!("expected integer capability");
|
||||
};
|
||||
assert_eq!((min, max), (128, 32_768));
|
||||
assert_eq!(special_values.len(), 1);
|
||||
assert_eq!(special_values[0].id, "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_effort_fields_are_mutually_exclusive() {
|
||||
let mut config = config(AiProvider::Qwen, "qwen3.8-max-preview");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("xhigh".to_string()));
|
||||
let mut body = json!({
|
||||
"reasoning_effort": "low",
|
||||
"thinking_budget": 1024,
|
||||
"enable_thinking": false
|
||||
});
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["reasoning_effort"], "xhigh");
|
||||
assert!(body.get("thinking_budget").is_none());
|
||||
assert!(body.get("enable_thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_25_never_sends_thinking_level() {
|
||||
let mut config = config(AiProvider::Gemini, "gemini-2.5-flash");
|
||||
config.runtime_effort = Some(AiEffortSelection::Integer(8192));
|
||||
let mut body = json!({ "generationConfig": {} });
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["generationConfig"]["thinkingConfig"]["thinkingBudget"], 8192);
|
||||
assert!(body["generationConfig"]["thinkingConfig"].get("thinkingLevel").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_and_chat_use_different_fields() {
|
||||
let mut responses = config(AiProvider::Openai, "gpt-5.6");
|
||||
responses.api_style = AiApiStyle::Responses;
|
||||
responses.runtime_effort = Some(AiEffortSelection::Enum("high".to_string()));
|
||||
let mut responses_body = json!({});
|
||||
apply_runtime_effort(&mut responses_body, &responses);
|
||||
assert_eq!(responses_body["reasoning"]["effort"], "high");
|
||||
|
||||
responses.api_style = AiApiStyle::Completions;
|
||||
let mut chat_body = json!({});
|
||||
apply_runtime_effort(&mut chat_body, &responses);
|
||||
assert_eq!(chat_body["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_capability_defaults_to_lowest_registered_level() {
|
||||
let capability = static_effort_capability(&config(AiProvider::Openai, "gpt-5.6"), "gpt-5.6").unwrap();
|
||||
let AiEffortCapability::Enum { default, .. } = capability else {
|
||||
panic!("expected enum capability");
|
||||
};
|
||||
assert_eq!(default, AiEffortSelection::Enum("none".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_capability_defaults_to_off_and_maps_thinking_fields() {
|
||||
let mut config = config(AiProvider::Deepseek, "deepseek-v4-pro");
|
||||
let capability = static_effort_capability(&config, "deepseek-v4-pro").unwrap();
|
||||
let AiEffortCapability::Enum { options, default, .. } = capability else {
|
||||
panic!("expected enum capability");
|
||||
};
|
||||
assert_eq!(options[0].selection, AiEffortSelection::Disabled);
|
||||
assert_eq!(default, AiEffortSelection::Disabled);
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Disabled);
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("max".to_string()));
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["reasoning_effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_boolean_effort_maps_to_enable_thinking() {
|
||||
let mut config = config(AiProvider::Qwen, "qwen3");
|
||||
config.runtime_effort = Some(AiEffortSelection::Boolean(true));
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["enable_thinking"], true);
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Disabled);
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["enable_thinking"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_3_uses_thinking_level() {
|
||||
let mut config = config(AiProvider::Gemini, "models/gemini-3-flash");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("medium".to_string()));
|
||||
let mut body = json!({ "generationConfig": {} });
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["generationConfig"]["thinkingConfig"]["thinkingLevel"], "medium");
|
||||
assert!(body["generationConfig"]["thinkingConfig"].get("thinkingBudget").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_gemini_budget() {
|
||||
let mut config = config(AiProvider::Gemini, "gemini-2.5-pro");
|
||||
config.runtime_effort = Some(AiEffortSelection::Integer(64));
|
||||
assert!(validate_runtime_effort(&config).is_err());
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Integer(-1));
|
||||
assert!(validate_runtime_effort(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_arbitrary_dynamic_cli_effort() {
|
||||
let mut config = config(AiProvider::ClaudeCodeCli, "sonnet");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("future-effort".to_string()));
|
||||
assert!(validate_runtime_effort(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_openai_compatibility_uses_reasoning_effort() {
|
||||
let mut config = config(AiProvider::Ollama, "gpt-oss");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("medium".to_string()));
|
||||
let mut body = json!({});
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["reasoning_effort"], "medium");
|
||||
assert!(body.get("think").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_boolean_effort_maps_to_openai_compatible_values() {
|
||||
let mut config = config(AiProvider::Ollama, "qwen3");
|
||||
config.runtime_effort = Some(AiEffortSelection::Boolean(true));
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["reasoning_effort"], "high");
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Disabled);
|
||||
let mut body = json!({});
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
assert_eq!(body["reasoning_effort"], "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_static_model_rejects_explicit_effort() {
|
||||
let mut config = config(AiProvider::Openai, "gpt-4o");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string()));
|
||||
assert!(validate_runtime_effort(&config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_default_does_not_change_existing_request_fields() {
|
||||
let mut config = config(AiProvider::Openai, "gpt-5.6");
|
||||
config.runtime_effort = Some(AiEffortSelection::ProviderDefault);
|
||||
let mut body = json!({ "reasoning_effort": "existing" });
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["reasoning_effort"], "existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_effort_maps_to_output_config() {
|
||||
let mut config = config(AiProvider::Claude, "claude-opus-4-6");
|
||||
config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string()));
|
||||
let mut body = json!({});
|
||||
|
||||
apply_runtime_effort(&mut body, &config);
|
||||
|
||||
assert_eq!(body["output_config"]["effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_custom_effort_text_bounds() {
|
||||
let mut config = config(AiProvider::Custom, "custom-model");
|
||||
config.runtime_effort = Some(AiEffortSelection::Text("provider-level".to_string()));
|
||||
assert!(validate_runtime_effort(&config).is_ok());
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Text("x".repeat(65)));
|
||||
assert!(validate_runtime_effort(&config).is_err());
|
||||
|
||||
config.runtime_effort = Some(AiEffortSelection::Text("invalid\nvalue".to_string()));
|
||||
assert!(validate_runtime_effort(&config).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
use crate::ai::{AiModelInfo, AiProvider};
|
||||
use serde_json::Value;
|
||||
|
||||
// Provider model-family exclusions last checked against official catalogs on 2026-07-27.
|
||||
|
||||
fn normalized_model_id(model_id: &str) -> String {
|
||||
let model_id = model_id.trim().trim_start_matches("models/").to_ascii_lowercase();
|
||||
let model_id = model_id.rsplit('/').next().unwrap_or(&model_id);
|
||||
model_id.strip_prefix("ft:").and_then(|model| model.split(':').next()).unwrap_or(model_id).to_string()
|
||||
}
|
||||
|
||||
fn is_openai_non_assistant_model(model: &str) -> bool {
|
||||
const PREFIXES: &[&str] = &[
|
||||
"babbage-002",
|
||||
"chatgpt-image",
|
||||
"computer-use",
|
||||
"dall-e",
|
||||
"davinci-002",
|
||||
"gpt-image",
|
||||
"omni-moderation",
|
||||
"sora",
|
||||
"text-embedding",
|
||||
"text-moderation",
|
||||
"tts",
|
||||
"whisper",
|
||||
];
|
||||
|
||||
PREFIXES.iter().any(|prefix| model.starts_with(prefix))
|
||||
|| model.contains("-audio")
|
||||
|| model.contains("-realtime")
|
||||
|| model.contains("-transcribe")
|
||||
|| model.contains("-tts")
|
||||
}
|
||||
|
||||
fn is_gemini_non_assistant_model(model: &str) -> bool {
|
||||
const PREFIXES: &[&str] = &["antigravity", "chirp", "imagen", "lyria", "nano-banana", "veo"];
|
||||
|
||||
PREFIXES.iter().any(|prefix| model.starts_with(prefix))
|
||||
|| model.contains("computer-use")
|
||||
|| model.contains("deep-research")
|
||||
|| model.contains("embedding")
|
||||
|| model.contains("-image")
|
||||
|| model.contains("gemini-omni")
|
||||
|| model.contains("image-generation")
|
||||
|| model.contains("-live")
|
||||
|| model.contains("live-")
|
||||
|| model.contains("native-audio")
|
||||
|| model.contains("robotics")
|
||||
|| model.contains("-tts")
|
||||
}
|
||||
|
||||
fn is_qwen_non_assistant_model(model: &str) -> bool {
|
||||
const PREFIXES: &[&str] = &[
|
||||
"ccai-",
|
||||
"cosyvoice",
|
||||
"flux-",
|
||||
"fun-asr",
|
||||
"paraformer",
|
||||
"qwen-image",
|
||||
"qwen-mt-",
|
||||
"qwen-tts",
|
||||
"qwen3-tts",
|
||||
"sambert",
|
||||
"sensevoice",
|
||||
"speech-",
|
||||
"stable-diffusion",
|
||||
"tongyi-tingwu",
|
||||
"wan",
|
||||
"wanx",
|
||||
"z-image",
|
||||
];
|
||||
|
||||
PREFIXES.iter().any(|prefix| model.starts_with(prefix))
|
||||
|| model.contains("-asr")
|
||||
|| model.contains("-captioner")
|
||||
|| model.contains("embedding")
|
||||
|| model.contains("livetranslate")
|
||||
|| model.contains("moderation")
|
||||
|| model.contains("-ocr")
|
||||
|| model.contains("realtime")
|
||||
|| model.contains("rerank")
|
||||
|| model.contains("-s2s")
|
||||
}
|
||||
|
||||
pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &str) -> bool {
|
||||
let model = normalized_model_id(model_id);
|
||||
match provider {
|
||||
AiProvider::Openai => !is_openai_non_assistant_model(&model),
|
||||
AiProvider::Gemini => !is_gemini_non_assistant_model(&model),
|
||||
AiProvider::Qwen => !is_qwen_non_assistant_model(&model),
|
||||
AiProvider::Claude
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Ollama
|
||||
| AiProvider::OpenaiCompatible
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::Custom => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retain_known_assistant_models(provider: &AiProvider, models: &mut Vec<AiModelInfo>) {
|
||||
models.retain(|model| model_is_assistant_compatible(provider, &model.id));
|
||||
}
|
||||
|
||||
pub(crate) fn gemini_item_is_assistant_compatible(item: &Value) -> bool {
|
||||
let Some(model_id) = item["name"].as_str().or_else(|| item["id"].as_str()) else {
|
||||
return false;
|
||||
};
|
||||
if !model_is_assistant_compatible(&AiProvider::Gemini, model_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(methods) = item["supportedGenerationMethods"].as_array() else {
|
||||
return true;
|
||||
};
|
||||
if methods.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
methods.iter().filter_map(Value::as_str).any(|method| {
|
||||
method.eq_ignore_ascii_case("generateContent") || method.eq_ignore_ascii_case("streamGenerateContent")
|
||||
})
|
||||
}
|
||||
|
||||
fn ollama_capability(data: &Value, expected: &str) -> Option<bool> {
|
||||
let capabilities = data["capabilities"].as_array()?;
|
||||
if capabilities.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(capabilities.iter().filter_map(Value::as_str).any(|capability| capability == expected))
|
||||
}
|
||||
|
||||
pub(crate) fn ollama_completion_capability(data: &Value) -> Option<bool> {
|
||||
ollama_capability(data, "completion")
|
||||
}
|
||||
|
||||
pub(crate) fn ollama_tool_capability(data: &Value) -> Option<bool> {
|
||||
ollama_capability(data, "tools")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
gemini_item_is_assistant_compatible, model_is_assistant_compatible, ollama_completion_capability,
|
||||
ollama_tool_capability,
|
||||
};
|
||||
use crate::ai::AiProvider;
|
||||
|
||||
#[test]
|
||||
fn openai_filter_keeps_assistant_models_and_hides_specialized_endpoints() {
|
||||
for model in ["gpt-4o", "gpt-5.6", "o4-mini", "ft:gpt-4o-mini:org:name:id"] {
|
||||
assert!(model_is_assistant_compatible(&AiProvider::Openai, model), "{model}");
|
||||
}
|
||||
for model in [
|
||||
"text-embedding-3-large",
|
||||
"gpt-image-1",
|
||||
"sora-2",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-mini-transcribe",
|
||||
"gpt-4o-realtime-preview",
|
||||
"tts-1-hd",
|
||||
] {
|
||||
assert!(!model_is_assistant_compatible(&AiProvider::Openai, model), "{model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_filter_keeps_multimodal_assistants_and_hides_non_chat_families() {
|
||||
for model in
|
||||
["qwen-plus", "qwen3-max", "qwen-vl-max", "qwen-omni-turbo", "qwen3.5-omni-plus", "qwen3.5-omni-flash"]
|
||||
{
|
||||
assert!(model_is_assistant_compatible(&AiProvider::Qwen, model), "{model}");
|
||||
}
|
||||
for model in [
|
||||
"ccai-pro",
|
||||
"qwen-mt-plus",
|
||||
"qwen-vl-ocr-2025-11-20",
|
||||
"qwen3.5-livetranslate-flash",
|
||||
"qwen3.5-omni-plus-realtime",
|
||||
"qwen3-omni-30b-a3b-captioner",
|
||||
"qwen3-s2s-flash-realtime",
|
||||
"text-embedding-v4",
|
||||
"tongyi-tingwu-slp",
|
||||
"z-image-turbo",
|
||||
"qwen3-vl-embedding",
|
||||
"qwen3-reranker",
|
||||
"qwen3-asr-flash",
|
||||
"qwen-moderation-latest",
|
||||
"wan2.1-t2v-turbo",
|
||||
"qwen-image-plus",
|
||||
"cosyvoice-v3-flash",
|
||||
] {
|
||||
assert!(!model_is_assistant_compatible(&AiProvider::Qwen, model), "{model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_providers_do_not_filter_unknown_model_taxonomies() {
|
||||
for provider in [AiProvider::OpenaiCompatible, AiProvider::Custom] {
|
||||
for model in ["text-embedding-private-chat", "company/image-reasoner", "future-model"] {
|
||||
assert!(model_is_assistant_compatible(&provider, model), "{}:{model}", provider.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_filter_uses_generation_methods_and_excludes_media_output_models() {
|
||||
assert!(gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": "models/gemini-2.5-pro",
|
||||
"supportedGenerationMethods": ["generateContent", "countTokens"]
|
||||
})));
|
||||
assert!(gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": "models/gemma-3-27b-it",
|
||||
"supportedGenerationMethods": ["generateContent"]
|
||||
})));
|
||||
assert!(!gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": "models/gemini-embedding-001",
|
||||
"supportedGenerationMethods": ["embedContent"]
|
||||
})));
|
||||
assert!(!gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": "models/gemini-3-pro-image-preview",
|
||||
"supportedGenerationMethods": ["generateContent"]
|
||||
})));
|
||||
for model in [
|
||||
"models/antigravity-preview-05-2026",
|
||||
"models/deep-research-preview-04-2026",
|
||||
"models/gemini-2.5-computer-use-preview-10-2025",
|
||||
"models/gemini-omni-flash-preview",
|
||||
"models/gemini-robotics-er-1.6-preview",
|
||||
"models/nano-banana-pro-preview",
|
||||
] {
|
||||
assert!(
|
||||
!gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": model,
|
||||
"supportedGenerationMethods": ["generateContent"]
|
||||
})),
|
||||
"{model}"
|
||||
);
|
||||
}
|
||||
assert!(gemini_item_is_assistant_compatible(&serde_json::json!({
|
||||
"name": "models/future-chat-model"
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_capabilities_only_exclude_explicit_non_completion_models() {
|
||||
assert_eq!(
|
||||
ollama_completion_capability(&serde_json::json!({ "capabilities": ["completion", "vision"] })),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(ollama_completion_capability(&serde_json::json!({ "capabilities": ["embedding"] })), Some(false));
|
||||
assert_eq!(ollama_completion_capability(&serde_json::json!({ "capabilities": [] })), None);
|
||||
assert_eq!(ollama_completion_capability(&serde_json::json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_tool_capability_uses_explicit_model_metadata() {
|
||||
assert_eq!(
|
||||
ollama_tool_capability(&serde_json::json!({ "capabilities": ["completion", "tools", "thinking"] })),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
ollama_tool_capability(&serde_json::json!({ "capabilities": ["completion", "thinking"] })),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(ollama_tool_capability(&serde_json::json!({ "capabilities": [] })), None);
|
||||
assert_eq!(ollama_tool_capability(&serde_json::json!({})), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1091,6 +1091,7 @@ mod tests {
|
|||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: crate::ai::AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pub mod ai;
|
|||
pub mod ai_claude_code_cli;
|
||||
pub mod ai_cli_agent;
|
||||
pub mod ai_codex_cli;
|
||||
pub mod ai_effort;
|
||||
mod ai_model_filter;
|
||||
pub mod changelog;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use rusqlite::{params, params_from_iter, types::Value, Connection, DatabaseName,
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::{AiChatMessage, AiConfig, AiConfigItem, AiConversation, AiProvider};
|
||||
use crate::ai::{AiChatMessage, AiChatSelectionState, AiConfig, AiConfigItem, AiConversation, AiProvider};
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
|
||||
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY,
|
||||
|
|
@ -30,6 +30,7 @@ const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs";
|
|||
const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions";
|
||||
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
|
||||
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
||||
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
||||
const USER_DATA_TABLES: &[&str] = &[
|
||||
"connections",
|
||||
"connection_secrets",
|
||||
|
|
@ -1684,6 +1685,18 @@ impl Storage {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn save_ai_chat_selection(&self, selection: &AiChatSelectionState) -> Result<(), String> {
|
||||
let value = serde_json::to_value(selection).map_err(|e| e.to_string())?;
|
||||
self.save_app_state_value(APP_STATE_AI_CHAT_SELECTION_KEY, &value).await
|
||||
}
|
||||
|
||||
pub async fn load_ai_chat_selection(&self) -> Result<Option<AiChatSelectionState>, String> {
|
||||
self.load_app_state_value(APP_STATE_AI_CHAT_SELECTION_KEY)
|
||||
.await?
|
||||
.map(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn load_or_create_local_device_secret(&self) -> Result<String, String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
if let Some(secret) = settings.get("local_device_secret").and_then(|value| value.as_str()) {
|
||||
|
|
@ -3490,6 +3503,7 @@ mod tests {
|
|||
maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, McpGlobalPolicy,
|
||||
McpGlobalPolicyState, Storage, MCP_GLOBAL_POLICY_KEY,
|
||||
};
|
||||
use crate::ai::{AiActiveModelSelection, AiChatSelectionState, AiEffortSelection, AiModelEffortPreference};
|
||||
use crate::connection_secrets::NACOS_RNACOS_CONSOLE_PASSWORD_KEY;
|
||||
use crate::connection_secrets::{
|
||||
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
|
||||
|
|
@ -4693,6 +4707,26 @@ mod tests {
|
|||
assert_eq!(storage.load_app_settings_json().await.unwrap().get("open_tabs"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_chat_selection_roundtrips_in_local_app_state() {
|
||||
let path = temp_db_path("ai-chat-selection");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
let selection = AiChatSelectionState {
|
||||
version: 1,
|
||||
active: Some(AiActiveModelSelection { config_id: "config-1".to_string(), model_id: "model-1".to_string() }),
|
||||
effort_preferences: vec![AiModelEffortPreference {
|
||||
config_id: "config-1".to_string(),
|
||||
model_id: "model-1".to_string(),
|
||||
selection: AiEffortSelection::Enum("high".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
storage.save_ai_chat_selection(&selection).await.unwrap();
|
||||
|
||||
assert_eq!(storage.load_ai_chat_selection().await.unwrap(), Some(selection));
|
||||
assert_eq!(storage.load_app_settings_json().await.unwrap().get("ai_chat_selection_v1"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tab_runtime_cache_roundtrips_binary_payloads() {
|
||||
let path = temp_db_path("tab-runtime-cache");
|
||||
|
|
@ -4877,6 +4911,7 @@ mod tests {
|
|||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: std::collections::HashMap::new(),
|
||||
|
|
|
|||
|
|
@ -300,7 +300,8 @@ impl DbxBackend for LocalBackend {
|
|||
arguments: Value,
|
||||
permissions: AgentSqlPermissions,
|
||||
) -> ToolResult {
|
||||
let call = ToolCall { id: format!("mcp-{tool_name}"), name: tool_name.to_string(), arguments };
|
||||
let call =
|
||||
ToolCall { id: format!("mcp-{tool_name}"), name: tool_name.to_string(), arguments, provider_payload: None };
|
||||
agent_tools::execute_tool(&call, &self.state, &connection.id, database, &connection.db_type, permissions).await
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -603,6 +603,7 @@ async fn main() {
|
|||
.route("/ai/config", post(routes::ai::save_ai_config).get(routes::ai::load_ai_config))
|
||||
.route("/ai/provider-config", post(routes::ai::save_ai_provider_config))
|
||||
.route("/ai/provider-configs", get(routes::ai::load_ai_provider_configs))
|
||||
.route("/ai/chat-selection", post(routes::ai::save_ai_chat_selection).get(routes::ai::load_ai_chat_selection))
|
||||
.route("/ai/configs", post(routes::ai::save_ai_configs).get(routes::ai::load_ai_configs))
|
||||
.route("/ai/default-config", post(routes::ai::set_default_ai_config))
|
||||
.route("/ai/config-item", post(routes::ai::save_ai_config_item))
|
||||
|
|
@ -616,6 +617,7 @@ async fn main() {
|
|||
.route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream))
|
||||
.route("/ai/test-connection", post(routes::ai::ai_test_connection))
|
||||
.route("/ai/models", post(routes::ai::ai_list_models))
|
||||
.route("/ai/model-effort", post(routes::ai::ai_resolve_model_effort))
|
||||
// Prompt templates
|
||||
.route(
|
||||
"/prompt-templates",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use serde::Deserialize;
|
|||
use dbx_core::agent_events::AgentEvent;
|
||||
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
|
||||
use dbx_core::ai::{
|
||||
AiCompletionRequest, AiConfig, AiConfigItem, AiConversation, AiModelInfo, AiProvider, AiStreamChunk,
|
||||
AiTestConnectionResult,
|
||||
AiChatSelectionState, AiCompletionRequest, AiConfig, AiConfigItem, AiConversation, AiEffortCapability, AiModelInfo,
|
||||
AiProvider, AiStreamChunk, AiTestConnectionResult,
|
||||
};
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
|
||||
|
|
@ -66,6 +66,19 @@ pub struct AiListModelsRequest {
|
|||
pub config: AiConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiResolveModelEffortRequest {
|
||||
pub config: AiConfig,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveAiChatSelectionRequest {
|
||||
pub selection: AiChatSelectionState,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiCancelStreamRequest {
|
||||
|
|
@ -149,6 +162,21 @@ pub async fn load_ai_provider_configs(
|
|||
Ok(Json(configs))
|
||||
}
|
||||
|
||||
pub async fn save_ai_chat_selection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiChatSelectionRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
state.app.storage.save_ai_chat_selection(&body.selection).await.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_ai_chat_selection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<Option<AiChatSelectionState>>, AppError> {
|
||||
let selection = state.app.storage.load_ai_chat_selection().await.map_err(AppError::from)?;
|
||||
Ok(Json(selection))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -265,6 +293,14 @@ pub async fn ai_list_models(Json(body): Json<AiListModelsRequest>) -> Result<Jso
|
|||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn ai_resolve_model_effort(
|
||||
Json(body): Json<AiResolveModelEffortRequest>,
|
||||
) -> Result<Json<AiEffortCapability>, AppError> {
|
||||
reject_web_unsupported_ai_provider(&body.config)?;
|
||||
let result = dbx_core::ai::resolve_model_effort_core(&body.config, &body.model_id).await.map_err(AppError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI cancel stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -437,6 +473,7 @@ mod tests {
|
|||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::Default,
|
||||
runtime_effort: None,
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: Default::default(),
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ test("AI composer labels an empty template selection explicitly", () => {
|
|||
});
|
||||
|
||||
test("AI composer exposes mode and action as one compact selector", () => {
|
||||
const footerStart = source.indexOf('<!-- Combined mode + action selector -->');
|
||||
const footerEnd = source.indexOf('<!-- Combined provider + model selector -->', footerStart);
|
||||
const footerStart = source.indexOf("<!-- Combined mode + action selector -->");
|
||||
const footerEnd = source.indexOf("<!-- Combined provider + model selector -->", footerStart);
|
||||
const footer = source.slice(footerStart, footerEnd);
|
||||
|
||||
assert.notEqual(footerStart, -1, "the combined mode and action selector should exist");
|
||||
|
|
@ -45,6 +45,20 @@ test("AI composer exposes mode and action as one compact selector", () => {
|
|||
assert.match(source, /function selectModeActionItem\(action: AiAction\) \{\s*\/\/ Vector databases[\s\S]*?if \(!showActionButtons\.value\) return;/);
|
||||
});
|
||||
|
||||
test("AI effort control opens as a hoverable side submenu", () => {
|
||||
const selectorStart = source.indexOf("<!-- Combined provider + model selector -->");
|
||||
const selectorEnd = source.indexOf("</template>", source.indexOf("</Popover>", selectorStart));
|
||||
const selector = source.slice(selectorStart, selectorEnd);
|
||||
|
||||
assert.notEqual(selectorStart, -1, "the combined provider and model selector should exist");
|
||||
assert.match(selector, /<Popover v-model:open="effortMenuOpen">/);
|
||||
assert.match(selector, /<PopoverAnchor as-child>/);
|
||||
assert.match(selector, /@mouseenter="openEffortMenu"/);
|
||||
assert.match(selector, /@mouseleave="scheduleEffortMenuClose"/);
|
||||
assert.match(selector, /<PopoverContent[\s\S]*?side="left"[\s\S]*?:side-offset="6"/);
|
||||
assert.doesNotMatch(selector, /v-if="effortPanelOpen"/);
|
||||
});
|
||||
|
||||
test("AI composer template remains compilable", () => {
|
||||
const { descriptor, errors } = parse(source, { filename: aiAssistantPath });
|
||||
assert.deepEqual(errors, []);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const apiMock = vi.hoisted(() => ({
|
|||
setDefaultAiConfig: vi.fn<[string]>().mockResolvedValue(undefined),
|
||||
loadAiConfigs: vi.fn<[]>().mockResolvedValue([]),
|
||||
saveAiConfigs: vi.fn<[unknown[]]>().mockResolvedValue(undefined),
|
||||
loadAiChatSelection: vi.fn<[]>().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn<[unknown]>().mockResolvedValue(undefined),
|
||||
loadEditorSettings: vi.fn<[]>().mockResolvedValue(null),
|
||||
saveEditorSettings: vi.fn<[unknown]>().mockResolvedValue(undefined),
|
||||
loadDesktopSettings: vi.fn<[]>().mockResolvedValue(null),
|
||||
|
|
@ -23,10 +25,7 @@ test("createAiConfig rejects -> state unchanged", async () => {
|
|||
apiMock.saveAiConfigItem.mockRejectedValueOnce(new Error("db error"));
|
||||
|
||||
const store = useSettingsStore();
|
||||
await assert.rejects(
|
||||
() => store.createAiConfig({ id: "c1", name: "test", isDefault: false } as any),
|
||||
/db error/,
|
||||
);
|
||||
await assert.rejects(() => store.createAiConfig({ id: "c1", name: "test", isDefault: false } as any), /db error/);
|
||||
assert.equal(store.aiConfigs.length, 0);
|
||||
});
|
||||
|
||||
|
|
@ -103,9 +102,7 @@ test("reloadAiConfigs resets and reloads from API", async () => {
|
|||
const store = useSettingsStore();
|
||||
store.aiConfigs.push({ id: "old", name: "stale", isDefault: true } as any);
|
||||
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([
|
||||
{ id: "fresh", name: "fresh", isDefault: true } as any,
|
||||
]);
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([{ id: "fresh", name: "fresh", model: "m", isDefault: true } as any]);
|
||||
|
||||
await store.reloadAiConfigs();
|
||||
assert.equal(store.aiConfigs.length, 1);
|
||||
|
|
@ -121,9 +118,7 @@ test("reloadAiConfigs falls back when active config was deleted", async () => {
|
|||
store.aiConfigs.push({ id: "remaining", name: "r", model: "m", isDefault: true } as any);
|
||||
store.activeModel = { configId: "deleted", modelId: "gone" };
|
||||
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([
|
||||
{ id: "remaining", name: "r", model: "m", isDefault: true } as any,
|
||||
]);
|
||||
apiMock.loadAiConfigs.mockResolvedValueOnce([{ id: "remaining", name: "r", model: "m", isDefault: true } as any]);
|
||||
|
||||
await store.reloadAiConfigs();
|
||||
assert.equal(store.aiConfigs.length, 1);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ pub async fn ai_list_models(config: AiConfig) -> Result<Vec<AiModelInfo>, String
|
|||
dbx_core::ai::list_models_core(&config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_resolve_model_effort(config: AiConfig, model_id: String) -> Result<AiEffortCapability, String> {
|
||||
let config = resolve_cli_provider_config(config);
|
||||
dbx_core::ai::resolve_model_effort_core(&config, &model_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ai_config(state: State<'_, Arc<AppState>>, config: AiConfig) -> Result<(), String> {
|
||||
state.storage.save_ai_config(&config).await
|
||||
|
|
@ -47,6 +53,19 @@ pub async fn load_ai_provider_configs(
|
|||
state.storage.load_ai_provider_configs().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ai_chat_selection(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
selection: AiChatSelectionState,
|
||||
) -> Result<(), String> {
|
||||
state.storage.save_ai_chat_selection(&selection).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_ai_chat_selection(state: State<'_, Arc<AppState>>) -> Result<Option<AiChatSelectionState>, String> {
|
||||
state.storage.load_ai_chat_selection().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_complete(request: AiCompletionRequest) -> Result<String, String> {
|
||||
dbx_core::ai::complete(&request).await
|
||||
|
|
|
|||
|
|
@ -1303,10 +1303,13 @@ pub fn run() {
|
|||
commands::ai::ai_cancel_stream,
|
||||
commands::ai::ai_test_connection,
|
||||
commands::ai::ai_list_models,
|
||||
commands::ai::ai_resolve_model_effort,
|
||||
commands::ai::save_ai_config,
|
||||
commands::ai::load_ai_config,
|
||||
commands::ai::save_ai_provider_config,
|
||||
commands::ai::load_ai_provider_configs,
|
||||
commands::ai::save_ai_chat_selection,
|
||||
commands::ai::load_ai_chat_selection,
|
||||
commands::ai::save_ai_conversation,
|
||||
commands::ai::load_ai_conversations,
|
||||
commands::ai::delete_ai_conversation,
|
||||
|
|
|
|||
Loading…
Reference in New Issue