feat(ai): unify model discovery and effort handling
This commit is contained in:
1 parent
c54b95e274
commit
c7e7b7c9d9
41 files changed
+3291
-634
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleSlash,
|
||||
Copy,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
} from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore";
|
||||
@@ -54,9 +55,10 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai";
|
||||
import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
|
||||
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
|
||||
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
|
||||
import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
|
||||
import { effortPreferenceUpdateForCapability, effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
|
||||
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
|
||||
import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
|
||||
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
@@ -70,6 +72,7 @@ import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
||||
import { aiCancelStream, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation } from "@/lib/backend/api";
|
||||
import type { AiMessage } from "@/lib/backend/api";
|
||||
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
|
||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
@@ -303,16 +306,24 @@ function onEditKeydown(event: KeyboardEvent, visibleIndex: number) {
|
||||
// Inline model selector
|
||||
const providerSelectorOpen = ref(false);
|
||||
const modelSearchQuery = ref("");
|
||||
const collapsedModelConfigIds = ref<Set<string>>(new Set());
|
||||
const effortMenuOpen = ref(false);
|
||||
const manualModelConfigId = ref("");
|
||||
const manualModelId = ref("");
|
||||
const effortTextValue = ref("");
|
||||
const effortIntegerValue = ref(0);
|
||||
let effortMenuCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const { catalogs: modelCatalogs, effortCatalogs, loadModels, resolveEffort, effortKey } = useAiModelCatalog();
|
||||
|
||||
// Configured providers for quick switching - get from aiConfigs
|
||||
const configuredProviders = computed(() => {
|
||||
const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey)));
|
||||
// Apply search filter - hide providers with no matching models
|
||||
if (modelSearchQuery.value.trim()) {
|
||||
const query = modelSearchQuery.value.trim().toLowerCase();
|
||||
return providers.filter((c) => {
|
||||
if (configMatchesModelQuery(c, query)) return true;
|
||||
const models = getModelsForConfig(c.id);
|
||||
return models.some((model) => model.toLowerCase().includes(query));
|
||||
return models.some((model) => model.id.toLowerCase().includes(query) || model.displayName?.toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
@@ -323,39 +334,184 @@ const activeFullConfig = computed(() => {
|
||||
const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId);
|
||||
if (!item) return null;
|
||||
const modelId = settings.activeModel.modelId;
|
||||
const config = normalizeAiConfig({ ...item, model: modelId });
|
||||
if (config.provider === "claude-code-cli") {
|
||||
config.reasoningLevel = normalizeClaudeCodeReasoningLevel(
|
||||
config.reasoningLevel,
|
||||
item.models?.find((model) => model.name === modelId),
|
||||
);
|
||||
}
|
||||
return config;
|
||||
return normalizeAiConfig({ ...item, model: modelId, runtimeEffort: runtimeEffortFromPreference(settings.activeEffort) });
|
||||
});
|
||||
|
||||
function getModelsForConfig(configId: string): string[] {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return [];
|
||||
return getAiConfigModelIds(config);
|
||||
function getModelsForConfig(configId: string) {
|
||||
return modelCatalogs.get(configId)?.models ?? [];
|
||||
}
|
||||
|
||||
function getConfigModelOptionIds(configId: string): string[] {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return [];
|
||||
let models = getModelsForConfig(configId);
|
||||
// Apply search filter
|
||||
if (modelSearchQuery.value.trim()) {
|
||||
const query = modelSearchQuery.value.trim().toLowerCase();
|
||||
models = models.filter((model) => model.toLowerCase().includes(query));
|
||||
function configMatchesModelQuery(config: AiConfigItem, query: string): boolean {
|
||||
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
function getConfigModelOptions(config: AiConfigItem) {
|
||||
const models = getModelsForConfig(config.id);
|
||||
const query = modelSearchQuery.value.trim().toLowerCase();
|
||||
if (!query || configMatchesModelQuery(config, query)) return models;
|
||||
return models.filter((model) => model.id.toLowerCase().includes(query) || model.displayName?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function getModelCatalog(configId: string) {
|
||||
return modelCatalogs.get(configId) ?? { status: "idle" as const, models: [] };
|
||||
}
|
||||
|
||||
function isModelConfigCollapsed(configId: string): boolean {
|
||||
return collapsedModelConfigIds.value.has(configId);
|
||||
}
|
||||
|
||||
function toggleModelConfig(configId: string) {
|
||||
const next = new Set(collapsedModelConfigIds.value);
|
||||
if (next.has(configId)) next.delete(configId);
|
||||
else next.add(configId);
|
||||
collapsedModelConfigIds.value = next;
|
||||
}
|
||||
|
||||
async function loadConfiguredModelCatalogs(force = false) {
|
||||
const configs = settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey));
|
||||
const queue = [...configs];
|
||||
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const config = queue.shift();
|
||||
if (!config) return;
|
||||
await loadModels(config, force).catch(() => {});
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
watch(providerSelectorOpen, (open) => {
|
||||
if (open) {
|
||||
void loadConfiguredModelCatalogs();
|
||||
} else {
|
||||
modelSearchQuery.value = "";
|
||||
closeEffortMenu();
|
||||
manualModelConfigId.value = "";
|
||||
manualModelId.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
async function ensureModelEffort(config: AiConfigItem, modelId: string, force = false) {
|
||||
try {
|
||||
const capability = await resolveEffort(config, modelId, force);
|
||||
const isActiveModel = settings.activeModel?.configId === config.id && settings.activeModel.modelId === modelId;
|
||||
if (isActiveModel) {
|
||||
const preferenceUpdate = effortPreferenceUpdateForCapability(capability, settings.activeEffort);
|
||||
if (preferenceUpdate !== undefined) settings.updateActiveEffort(preferenceUpdate);
|
||||
}
|
||||
syncEffortInputs(capability);
|
||||
} catch {
|
||||
// The effort section exposes the scoped retry state.
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function handleModelSelect(configId: string, modelId: string) {
|
||||
const config = settings.aiConfigs.find((c) => c.id === configId);
|
||||
if (!config) return;
|
||||
settings.updateActiveModel({ configId, modelId });
|
||||
providerSelectorOpen.value = false;
|
||||
closeEffortMenu();
|
||||
}
|
||||
|
||||
function startManualModel(configId: string) {
|
||||
manualModelConfigId.value = configId;
|
||||
manualModelId.value = settings.activeModel?.configId === configId ? settings.activeModel.modelId : "";
|
||||
nextTick(() => document.querySelector<HTMLInputElement>("[data-manual-model-input]")?.focus());
|
||||
}
|
||||
|
||||
function applyManualModel(configId: string) {
|
||||
const modelId = manualModelId.value.trim();
|
||||
if (!modelId) return;
|
||||
handleModelSelect(configId, modelId);
|
||||
manualModelConfigId.value = "";
|
||||
manualModelId.value = "";
|
||||
}
|
||||
|
||||
const activeEffortEntry = computed(() => {
|
||||
const active = settings.activeModel;
|
||||
if (!active) return undefined;
|
||||
return effortCatalogs.get(effortKey(active.configId, active.modelId));
|
||||
});
|
||||
|
||||
const activeEffortCapability = computed(() => activeEffortEntry.value?.capability);
|
||||
|
||||
function syncEffortInputs(capability = activeEffortCapability.value) {
|
||||
const selection = settings.activeEffort;
|
||||
effortTextValue.value = selection?.kind === "text" ? selection.value : "";
|
||||
if (capability?.kind === "integer") {
|
||||
const selectedValue = selection?.kind === "integer" ? selection.value : undefined;
|
||||
const defaultValue = capability.default.kind === "integer" ? capability.default.value : undefined;
|
||||
effortIntegerValue.value = selectedValue !== undefined && selectedValue >= capability.min && selectedValue <= capability.max ? selectedValue : defaultValue !== undefined && defaultValue >= capability.min && defaultValue <= capability.max ? defaultValue : capability.min;
|
||||
}
|
||||
}
|
||||
|
||||
function clearEffortMenuCloseTimer() {
|
||||
if (!effortMenuCloseTimer) return;
|
||||
clearTimeout(effortMenuCloseTimer);
|
||||
effortMenuCloseTimer = null;
|
||||
}
|
||||
|
||||
function openEffortMenu() {
|
||||
clearEffortMenuCloseTimer();
|
||||
if (settings.activeModel) effortMenuOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEffortMenu() {
|
||||
clearEffortMenuCloseTimer();
|
||||
effortMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function scheduleEffortMenuClose() {
|
||||
clearEffortMenuCloseTimer();
|
||||
effortMenuCloseTimer = setTimeout(() => {
|
||||
effortMenuOpen.value = false;
|
||||
effortMenuCloseTimer = null;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
watch(effortMenuOpen, (open) => {
|
||||
const active = settings.activeModel;
|
||||
if (!open || !active) return;
|
||||
const config = settings.aiConfigs.find((item) => item.id === active.configId);
|
||||
if (config) void ensureModelEffort(config, active.modelId);
|
||||
});
|
||||
|
||||
function selectEffort(selection: AiEffortSelection) {
|
||||
settings.updateActiveEffort(selection);
|
||||
syncEffortInputs();
|
||||
}
|
||||
|
||||
function selectEffortOption(option: AiEffortOption) {
|
||||
selectEffort(option.selection);
|
||||
}
|
||||
|
||||
function commitIntegerEffort(capability: Extract<AiEffortCapability, { kind: "integer" }>) {
|
||||
const steppedValue = capability.min + Math.round((effortIntegerValue.value - capability.min) / capability.step) * capability.step;
|
||||
const value = Math.min(capability.max, Math.max(capability.min, steppedValue));
|
||||
effortIntegerValue.value = value;
|
||||
selectEffort({ kind: "integer", value });
|
||||
}
|
||||
|
||||
function commitTextEffort() {
|
||||
const value = effortTextValue.value.trim();
|
||||
settings.updateActiveEffort(value ? { kind: "text", value } : { kind: "providerDefault" });
|
||||
}
|
||||
|
||||
function effortSelectionLabel(selection: AiEffortSelection | null): string {
|
||||
if (!selection || selection.kind === "providerDefault") return t("ai.providerDefault");
|
||||
const capability = activeEffortCapability.value;
|
||||
const options = capability?.kind === "enum" ? capability.options : capability?.kind === "integer" ? capability.specialValues : undefined;
|
||||
const matchingOption = options?.find((option) => effortSelectionEquals(selection, option.selection));
|
||||
if (matchingOption) return matchingOption.label;
|
||||
if (selection.kind === "disabled") return t("ai.effortDisabled");
|
||||
if (selection.kind === "boolean") return selection.value ? t("ai.effortEnabled") : t("ai.effortDisabled");
|
||||
return String(selection.value);
|
||||
}
|
||||
|
||||
function retryActiveEffort() {
|
||||
const active = settings.activeModel;
|
||||
if (!active) return;
|
||||
const config = settings.aiConfigs.find((item) => item.id === active.configId);
|
||||
if (config) void ensureModelEffort(config, active.modelId, true);
|
||||
}
|
||||
|
||||
/** Deferred context compaction info; applied after stream ends to avoid shifting assistantIdx. */
|
||||
@@ -1882,6 +2038,7 @@ function stopResize() {
|
||||
onUnmounted(() => {
|
||||
if (assistantDeltaFrame !== null) cancelAnimationFrame(assistantDeltaFrame);
|
||||
clearTimeout(mentionTimer);
|
||||
clearEffortMenuCloseTimer();
|
||||
cancelStream();
|
||||
detachMessageScrollListener();
|
||||
// 清理拖拽事件监听,防止内存泄漏
|
||||
@@ -2408,51 +2565,188 @@ async function openExternalUrl(url: string) {
|
||||
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-80 gap-0 p-1.5"
|
||||
@open-auto-focus.prevent
|
||||
@update:open="
|
||||
(open: boolean) => {
|
||||
if (!open) modelSearchQuery = '';
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<PopoverContent align="end" class="max-h-(--reka-popover-content-available-height) w-80 gap-0 overflow-y-auto p-1.5" @open-auto-focus.prevent>
|
||||
<div class="relative px-1 pb-1">
|
||||
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input v-model="modelSearchQuery" type="text" :placeholder="t('ai.searchModels')" class="w-full rounded-sm border bg-background py-1.5 pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-primary" @click.stop />
|
||||
</div>
|
||||
<!-- All configured providers with their models -->
|
||||
<div class="max-h-80 overflow-auto">
|
||||
<template v-for="config in configuredProviders" :key="config.id">
|
||||
<!-- Provider header -->
|
||||
<div class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs" :class="config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : 'text-foreground'">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-muted"
|
||||
:class="config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : 'text-foreground'"
|
||||
:aria-expanded="!isModelConfigCollapsed(config.id)"
|
||||
@click="toggleModelConfig(config.id)"
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5 shrink-0 transition-transform" :class="{ 'rotate-90': !isModelConfigCollapsed(config.id) }" />
|
||||
<AiProviderLogo :provider="config.provider" :label="AI_PROVIDER_PRESETS[config.provider]?.label ?? config.provider" :icon-slug="AI_PROVIDER_PRESETS[config.provider]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="font-medium">{{ config.name }}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ config.name }}</span>
|
||||
<Loader2 v-if="getModelCatalog(config.id).status === 'loading'" class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
<span v-if="config.isDefault" class="ml-auto text-[10px] text-muted-foreground">{{ t("ai.default") }}</span>
|
||||
</div>
|
||||
<!-- No models hint -->
|
||||
<div v-if="!getConfigModelOptionIds(config.id).length" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
{{ t("ai.noModel") }}
|
||||
</div>
|
||||
<!-- Model list -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="modelId in getConfigModelOptionIds(config.id)"
|
||||
:key="modelId"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
:class="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="handleModelSelect(config.id, modelId)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ modelId }}</span>
|
||||
<Check v-if="modelId === settings.activeModel?.modelId && config.id === settings.activeModel?.configId" class="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
</button>
|
||||
<div v-if="!isModelConfigCollapsed(config.id)">
|
||||
<div v-if="getModelCatalog(config.id).status === 'loading' && !getModelsForConfig(config.id).length" class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("ai.loadingModels") }}
|
||||
</div>
|
||||
<div v-else-if="getModelCatalog(config.id).status === 'error' && !getModelsForConfig(config.id).length" class="space-y-1 px-2 py-2 text-xs text-muted-foreground">
|
||||
<div class="truncate" :title="getModelCatalog(config.id).error">{{ t("ai.modelLoadFailed") }}</div>
|
||||
<button type="button" class="text-primary hover:underline" @click="loadModels(config, true)">{{ t("ai.retry") }}</button>
|
||||
</div>
|
||||
<div v-else-if="getModelCatalog(config.id).status === 'ready' && !getConfigModelOptions(config).length" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
{{ modelSearchQuery.trim() ? t("ai.noModelMatch") : t("ai.noModels") }}
|
||||
</div>
|
||||
<template v-if="getConfigModelOptions(config).length">
|
||||
<button
|
||||
v-for="model in getConfigModelOptions(config)"
|
||||
:key="model.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
:class="model.id === settings.activeModel?.modelId && config.id === settings.activeModel?.configId ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="handleModelSelect(config.id, model.id)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ model.displayName || model.id }}
|
||||
<span v-if="model.displayName && model.displayName !== model.id" class="ml-1 text-[10px] text-muted-foreground">{{ model.id }}</span>
|
||||
</span>
|
||||
<Check v-if="model.id === settings.activeModel?.modelId && config.id === settings.activeModel?.configId" class="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="getModelCatalog(config.id).status === 'error' && getModelsForConfig(config.id).length" class="flex items-center justify-between gap-2 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
<span class="truncate" :title="getModelCatalog(config.id).error">{{ t("ai.modelLoadFailed") }}</span>
|
||||
<button type="button" class="shrink-0 text-primary hover:underline" @click="loadModels(config, true)">{{ t("ai.retry") }}</button>
|
||||
</div>
|
||||
<form v-if="manualModelConfigId === config.id" class="flex items-center gap-1 px-2 py-1" @submit.prevent="applyManualModel(config.id)">
|
||||
<input v-model="manualModelId" data-manual-model-input type="text" :placeholder="t('ai.manualModelPlaceholder')" class="min-w-0 flex-1 rounded-sm border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary" @click.stop />
|
||||
<Button type="submit" size="sm" class="h-6 px-2 text-[10px]" :disabled="!manualModelId.trim()">{{ t("common.confirm") }}</Button>
|
||||
</form>
|
||||
<button v-else type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" @click="startManualModel(config.id)">
|
||||
<Pencil class="h-3 w-3" />
|
||||
{{ t("ai.manualModel") }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="my-1 border-t" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="settings.activeModel" class="border-t pt-1">
|
||||
<Popover v-model:open="effortMenuOpen">
|
||||
<PopoverAnchor as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
:aria-expanded="effortMenuOpen"
|
||||
aria-haspopup="menu"
|
||||
@mouseenter="openEffortMenu"
|
||||
@mouseleave="scheduleEffortMenuClose"
|
||||
@focus="openEffortMenu"
|
||||
@click.stop="openEffortMenu"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ t("ai.effort") }}</span>
|
||||
<span class="ml-auto max-w-[160px] truncate text-muted-foreground">{{ effortSelectionLabel(settings.activeEffort) }}</span>
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="end"
|
||||
:side-offset="6"
|
||||
:collision-padding="8"
|
||||
class="max-h-(--reka-popover-content-available-height) w-72 gap-1 overflow-y-auto p-2"
|
||||
@mouseenter="openEffortMenu"
|
||||
@mouseleave="scheduleEffortMenuClose"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
:class="!settings.activeEffort || settings.activeEffort.kind === 'providerDefault' ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="selectEffort({ kind: 'providerDefault' })"
|
||||
>
|
||||
<span class="flex-1">{{ t("ai.providerDefault") }}</span>
|
||||
<Check v-if="!settings.activeEffort || settings.activeEffort.kind === 'providerDefault'" class="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
<div v-if="activeEffortEntry?.status === 'loading'" class="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("ai.loadingEffort") }}
|
||||
</div>
|
||||
<div v-else-if="activeEffortEntry?.status === 'error'" class="flex items-center justify-between gap-2 py-2 text-xs text-muted-foreground">
|
||||
<span class="truncate" :title="activeEffortEntry.error">{{ t("ai.effortLoadFailed") }}</span>
|
||||
<button type="button" class="shrink-0 text-primary hover:underline" @click="retryActiveEffort">
|
||||
{{ t("ai.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-else-if="activeEffortCapability?.kind === 'enum'">
|
||||
<button
|
||||
v-for="option in activeEffortCapability.options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
:class="effortSelectionEquals(settings.activeEffort, option.selection) ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="selectEffortOption(option)"
|
||||
>
|
||||
<span class="flex-1">{{ option.label }}</span>
|
||||
<Check v-if="effortSelectionEquals(settings.activeEffort, option.selection)" class="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="activeEffortCapability?.kind === 'integer'">
|
||||
<button
|
||||
v-for="option in activeEffortCapability.specialValues"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
:class="effortSelectionEquals(settings.activeEffort, option.selection) ? 'bg-accent text-accent-foreground' : ''"
|
||||
@click="selectEffortOption(option)"
|
||||
>
|
||||
<span class="flex-1">{{ option.label }}</span>
|
||||
<Check v-if="effortSelectionEquals(settings.activeEffort, option.selection)" class="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<input v-model.number="effortIntegerValue" type="range" class="min-w-0 flex-1" :min="activeEffortCapability.min" :max="activeEffortCapability.max" :step="activeEffortCapability.step" @change="commitIntegerEffort(activeEffortCapability)" />
|
||||
<input
|
||||
v-model.number="effortIntegerValue"
|
||||
type="number"
|
||||
class="w-20 rounded-sm border bg-background px-2 py-1 text-xs"
|
||||
:min="activeEffortCapability.min"
|
||||
:max="activeEffortCapability.max"
|
||||
:step="activeEffortCapability.step"
|
||||
@change="commitIntegerEffort(activeEffortCapability)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEffortCapability?.kind === 'boolean'">
|
||||
<button type="button" class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent" @click="selectEffort({ kind: 'boolean', value: true })">
|
||||
<span class="flex-1 text-left">{{ t("ai.effortEnabled") }}</span>
|
||||
<Check v-if="settings.activeEffort?.kind === 'boolean' && settings.activeEffort.value" class="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
<button type="button" class="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent" @click="selectEffort({ kind: 'boolean', value: false })">
|
||||
<span class="flex-1 text-left">{{ t("ai.effortDisabled") }}</span>
|
||||
<Check v-if="settings.activeEffort?.kind === 'boolean' && !settings.activeEffort.value" class="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
</template>
|
||||
<form v-else-if="activeEffortCapability?.kind === 'freeText'" class="flex items-center gap-1 py-1" @submit.prevent="commitTextEffort">
|
||||
<input
|
||||
v-model="effortTextValue"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:placeholder="activeEffortCapability.placeholder || t('ai.customEffortPlaceholder')"
|
||||
class="min-w-0 flex-1 rounded-sm border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary"
|
||||
@click.stop
|
||||
@blur="commitTextEffort"
|
||||
/>
|
||||
<Button type="submit" size="sm" class="h-6 px-2 text-[10px]">{{ t("common.confirm") }}</Button>
|
||||
</form>
|
||||
<div v-else-if="activeEffortCapability?.kind === 'unsupported'" class="px-2 py-2 text-xs text-muted-foreground">
|
||||
{{ t("ai.effortUnsupported") }}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
type AiApiStyle,
|
||||
type AiAuthMethod,
|
||||
type AiConfiguredModel,
|
||||
type AiEffortLevel,
|
||||
type AiReasoningLevel,
|
||||
type EditorTheme,
|
||||
type DesktopIconTheme,
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
||||
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
|
||||
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
|
||||
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
|
||||
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
|
||||
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
|
||||
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
|
||||
@@ -81,7 +78,6 @@ import {
|
||||
webdavSyncTest,
|
||||
webdavSyncUpload,
|
||||
type AppSupportInfo,
|
||||
type AiModelInfo,
|
||||
type McpServerStatus,
|
||||
type SnippetProvider,
|
||||
type SnippetSyncConfig,
|
||||
@@ -2199,16 +2195,6 @@ async function saveMaxAgentTurnsSetting() {
|
||||
const aiDeleteConfirmOpen = ref(false);
|
||||
const aiDeleteConfigId = ref<string | null>(null);
|
||||
|
||||
// Model list management
|
||||
const aiEditModels = ref<AiConfiguredModel[]>([]);
|
||||
const aiModelListLoading = ref(false);
|
||||
let aiModelListRequestToken = 0;
|
||||
|
||||
// AI Model Multi-Select
|
||||
const aiModelMultiSelectOpen = ref(false);
|
||||
const aiModelMultiSelectSearch = ref("");
|
||||
const aiFetchedModels = ref<AiModelInfo[]>([]);
|
||||
|
||||
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli"]);
|
||||
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
|
||||
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
|
||||
@@ -2218,6 +2204,7 @@ const aiEditApiKey = ref("");
|
||||
const aiEditAuthMethod = ref<AiAuthMethod>("api-key");
|
||||
const aiEditEndpoint = ref("");
|
||||
const aiEditModel = ref("");
|
||||
const aiEditLegacyModels = ref<AiConfiguredModel[]>([]);
|
||||
const aiEditApiStyle = ref<AiApiStyle>("completions");
|
||||
const aiEditProxyEnabled = ref(false);
|
||||
const aiEditProxyUrl = ref("");
|
||||
@@ -2229,24 +2216,7 @@ const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
const aiEditClaudeCodeCliPath = ref("");
|
||||
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
|
||||
const aiModelError = ref("");
|
||||
|
||||
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
|
||||
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
|
||||
const codexReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [
|
||||
{ value: "default", labelKey: "ai.reasoningLevelDefault" },
|
||||
{ value: "minimal", labelKey: "ai.reasoningLevelMinimal" },
|
||||
{ value: "low", labelKey: "ai.reasoningLevelLow" },
|
||||
{ value: "medium", labelKey: "ai.reasoningLevelMedium" },
|
||||
{ value: "high", labelKey: "ai.reasoningLevelHigh" },
|
||||
];
|
||||
const effortLevelLabelKeys: Record<AiEffortLevel, string> = {
|
||||
low: "ai.reasoningLevelLow",
|
||||
medium: "ai.reasoningLevelMedium",
|
||||
high: "ai.reasoningLevelHigh",
|
||||
xhigh: "ai.reasoningLevelXhigh",
|
||||
max: "ai.reasoningLevelMax",
|
||||
};
|
||||
|
||||
const aiTesting = ref(false);
|
||||
const aiTestResult = ref<"" | "success" | "error">("");
|
||||
@@ -2329,8 +2299,6 @@ const aiCliMcpActionLabel = computed(() => {
|
||||
if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton");
|
||||
return t("settings.mcpUpToDate");
|
||||
});
|
||||
const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini");
|
||||
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCliProvider.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
|
||||
const aiCliEnvError = computed(() => cliEnvValidationError());
|
||||
const aiCliPathError = computed(() => {
|
||||
const path = aiEditCliPath.value.trim();
|
||||
@@ -2375,18 +2343,21 @@ function removeCliEnvRow(id: string) {
|
||||
}
|
||||
|
||||
function currentAiEditConfig() {
|
||||
const reasoningLevel = aiIsClaudeCodeCli.value ? normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value) : aiEditReasoningLevel.value;
|
||||
return {
|
||||
provider: aiEditProvider.value,
|
||||
apiKey: aiEditApiKey.value.trim(),
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
endpoint: aiEditEndpoint.value,
|
||||
model: aiEditModel.value,
|
||||
models: aiEditLegacyModels.value.map((model) => ({
|
||||
...model,
|
||||
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
|
||||
})),
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value,
|
||||
enableThinking: aiEditEnableThinking.value,
|
||||
reasoningLevel,
|
||||
reasoningLevel: aiEditReasoningLevel.value,
|
||||
contextWindow: aiEditContextWindow.value || undefined,
|
||||
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
|
||||
codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {},
|
||||
@@ -2412,16 +2383,12 @@ function aiSelectProvider(provider: AiProvider) {
|
||||
aiEditApiKey.value = "";
|
||||
aiEditAuthMethod.value = preset.authMethod;
|
||||
aiEditEndpoint.value = preset.endpoint;
|
||||
aiEditModel.value = preset.model;
|
||||
aiEditModel.value = "";
|
||||
aiEditLegacyModels.value = [];
|
||||
aiEditApiStyle.value = preset.apiStyle;
|
||||
aiEditEnableThinking.value = true;
|
||||
aiEditReasoningLevel.value = "default";
|
||||
aiEditModels.value = [];
|
||||
aiFetchedModels.value = [];
|
||||
aiLastModelFetchSignature = "";
|
||||
aiModelListRequestToken += 1;
|
||||
aiModelListLoading.value = false;
|
||||
if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus();
|
||||
if (provider === "claude-code-cli") void aiFetchModelList();
|
||||
}
|
||||
|
||||
function aiSelectApiStyle(style: AiApiStyle) {
|
||||
@@ -2449,6 +2416,10 @@ function aiEnterEditMode(configId?: string) {
|
||||
aiEditAuthMethod.value = config.authMethod;
|
||||
aiEditEndpoint.value = config.endpoint;
|
||||
aiEditModel.value = config.model;
|
||||
aiEditLegacyModels.value = (config.models ?? []).map((model) => ({
|
||||
...model,
|
||||
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
|
||||
}));
|
||||
aiEditApiStyle.value = config.apiStyle;
|
||||
aiEditProxyEnabled.value = config.proxyEnabled ?? false;
|
||||
aiEditProxyUrl.value = config.proxyUrl ?? "";
|
||||
@@ -2459,16 +2430,15 @@ function aiEnterEditMode(configId?: string) {
|
||||
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
|
||||
aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? "";
|
||||
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
|
||||
aiEditModels.value = config.models ? config.models.map((model) => ({ ...model, supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined })) : [];
|
||||
}
|
||||
} else {
|
||||
aiEditConfigName.value = "";
|
||||
aiEditModels.value = [];
|
||||
aiEditProvider.value = "claude";
|
||||
aiEditApiKey.value = "";
|
||||
aiEditAuthMethod.value = AI_PROVIDER_PRESETS["claude"].authMethod;
|
||||
aiEditEndpoint.value = AI_PROVIDER_PRESETS["claude"].endpoint;
|
||||
aiEditModel.value = AI_PROVIDER_PRESETS["claude"].model;
|
||||
aiEditModel.value = "";
|
||||
aiEditLegacyModels.value = [];
|
||||
aiEditApiStyle.value = AI_PROVIDER_PRESETS["claude"].apiStyle;
|
||||
aiEditProxyEnabled.value = false;
|
||||
aiEditProxyUrl.value = "";
|
||||
@@ -2480,194 +2450,6 @@ function aiEnterEditMode(configId?: string) {
|
||||
aiEditClaudeCodeCliPath.value = "";
|
||||
aiEditClaudeCodeCliEnvRows.value = [];
|
||||
}
|
||||
aiFetchedModels.value = [];
|
||||
aiLastModelFetchSignature = "";
|
||||
if (aiIsClaudeCodeCli.value) void aiFetchModelList();
|
||||
}
|
||||
|
||||
function aiAddModel() {
|
||||
aiEditModels.value.push({ name: "", label: "" });
|
||||
}
|
||||
|
||||
function aiRemoveModel(index: number) {
|
||||
aiEditModels.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function aiFetchModelList() {
|
||||
if (aiModelListLoading.value) return;
|
||||
if (!aiCanListModels.value) return;
|
||||
const token = ++aiModelListRequestToken;
|
||||
const signature = aiModelFetchSignature.value;
|
||||
aiModelListLoading.value = true;
|
||||
aiModelError.value = "";
|
||||
try {
|
||||
const models = await aiListModels({
|
||||
provider: aiEditProvider.value,
|
||||
apiKey: aiEditApiKey.value.trim(),
|
||||
endpoint: aiEditEndpoint.value,
|
||||
model: aiEditModel.value,
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
proxyEnabled: aiEditProxyEnabled.value,
|
||||
proxyUrl: aiEditProxyUrl.value,
|
||||
enableThinking: aiEditEnableThinking.value,
|
||||
reasoningLevel: aiEditReasoningLevel.value,
|
||||
contextWindow: aiEditContextWindow.value,
|
||||
codexCliPath: aiEditCodexCliPath.value,
|
||||
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
|
||||
claudeCodeCliPath: aiEditClaudeCodeCliPath.value,
|
||||
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
|
||||
});
|
||||
if (token !== aiModelListRequestToken) return;
|
||||
aiFetchedModels.value = normalizeAiModelOptions(models);
|
||||
aiLastModelFetchSignature = signature;
|
||||
const fetchedById = new Map(aiFetchedModels.value.map((model) => [model.id, model]));
|
||||
aiEditModels.value = aiEditModels.value.map((model) => {
|
||||
const fetched = fetchedById.get(model.name);
|
||||
if (!fetched) return model;
|
||||
return {
|
||||
name: model.name,
|
||||
label: fetched.displayName || model.label,
|
||||
supportedEffortLevels: fetched.supportedEffortLevels,
|
||||
};
|
||||
});
|
||||
if (aiIsClaudeCodeCli.value) {
|
||||
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (token === aiModelListRequestToken) {
|
||||
aiModelError.value = e?.message || String(e);
|
||||
}
|
||||
} finally {
|
||||
if (token === aiModelListRequestToken) aiModelListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function aiIsModelSelected(modelId: string): boolean {
|
||||
return aiEditModels.value.some((m) => m.name === modelId);
|
||||
}
|
||||
|
||||
function aiToggleModel(model: AiModelInfo) {
|
||||
const index = aiEditModels.value.findIndex((m) => m.name === model.id);
|
||||
if (index >= 0) {
|
||||
aiEditModels.value.splice(index, 1);
|
||||
} else {
|
||||
aiEditModels.value.push({
|
||||
name: model.id,
|
||||
label: model.displayName || model.id,
|
||||
supportedEffortLevels: normalizeAiModelEffortLevels(model.supportedEffortLevels),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const aiFilteredFetchedModels = computed(() => {
|
||||
const search = aiModelMultiSelectSearch.value.trim().toLowerCase();
|
||||
if (!search) return aiFetchedModels.value;
|
||||
return aiFetchedModels.value.filter((m) => m.id.toLowerCase().includes(search) || (m.displayName && m.displayName.toLowerCase().includes(search)));
|
||||
});
|
||||
|
||||
const aiModelFetchSignature = computed(() =>
|
||||
JSON.stringify({
|
||||
provider: aiEditProvider.value,
|
||||
endpoint: aiEditEndpoint.value.trim(),
|
||||
apiKey: aiEditApiKey.value.trim(),
|
||||
authMethod: aiEditAuthMethod.value,
|
||||
apiStyle: aiEditApiStyle.value,
|
||||
codexCliPath: aiEditCodexCliPath.value.trim(),
|
||||
codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value),
|
||||
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim(),
|
||||
claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value),
|
||||
}),
|
||||
);
|
||||
|
||||
let aiLastModelFetchSignature = "";
|
||||
|
||||
function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: AiModelInfo[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id?.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
|
||||
normalized.push({
|
||||
id,
|
||||
displayName: model.displayName?.trim() || undefined,
|
||||
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const aiSelectedModelInfo = computed<AiModelInfo | undefined>(() => {
|
||||
if (aiModelFetchSignature.value === aiLastModelFetchSignature) {
|
||||
const fetched = aiFetchedModels.value.find((model) => model.id === aiEditModel.value);
|
||||
if (fetched) return fetched;
|
||||
}
|
||||
const configured = aiEditModels.value.find((model) => model.name === aiEditModel.value);
|
||||
if (!configured) return undefined;
|
||||
return {
|
||||
id: configured.name,
|
||||
displayName: configured.label,
|
||||
supportedEffortLevels: configured.supportedEffortLevels,
|
||||
};
|
||||
});
|
||||
const aiClaudeCodeEffortLevels = computed(() => normalizeAiModelEffortLevels(aiSelectedModelInfo.value?.supportedEffortLevels));
|
||||
const aiReasoningLevelOptions = computed<Array<{ value: AiReasoningLevel; labelKey: string }>>(() => {
|
||||
if (!aiIsClaudeCodeCli.value) return codexReasoningLevelOptions;
|
||||
return [{ value: "default", labelKey: "ai.reasoningLevelDefault" }, ...aiClaudeCodeEffortLevels.value.map((value) => ({ value, labelKey: effortLevelLabelKeys[value] }))];
|
||||
});
|
||||
const aiReasoningLevelDisabled = computed(() => aiIsClaudeCodeCli.value && aiClaudeCodeEffortLevels.value.length === 0);
|
||||
const aiReasoningLevelHint = computed(() => {
|
||||
if (!aiIsClaudeCodeCli.value) return t("ai.reasoningLevelHint");
|
||||
if (aiModelListLoading.value && aiClaudeCodeEffortLevels.value.length === 0) return t("ai.loadingModels");
|
||||
return aiClaudeCodeEffortLevels.value.length ? t("ai.claudeCodeEffortHint") : t("ai.claudeCodeEffortUnavailable");
|
||||
});
|
||||
|
||||
watch([aiIsClaudeCodeCli, aiSelectedModelInfo], () => {
|
||||
if (!aiIsClaudeCodeCli.value) return;
|
||||
aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value);
|
||||
});
|
||||
|
||||
function aiModelsForSave(): AiConfiguredModel[] | undefined {
|
||||
const models = aiEditModels.value
|
||||
.map((model) => {
|
||||
const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels);
|
||||
return {
|
||||
name: model.name.trim(),
|
||||
label: model.label?.trim() || undefined,
|
||||
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
|
||||
};
|
||||
})
|
||||
.filter((model) => model.name);
|
||||
|
||||
if (aiIsClaudeCodeCli.value && aiEditModel.value.trim() && aiSelectedModelInfo.value) {
|
||||
const modelId = aiEditModel.value.trim();
|
||||
const supportedEffortLevels = normalizeAiModelEffortLevels(aiSelectedModelInfo.value.supportedEffortLevels);
|
||||
const configuredModel = models.find((model) => model.name === modelId);
|
||||
if (configuredModel) {
|
||||
configuredModel.label = aiSelectedModelInfo.value.displayName || configuredModel.label;
|
||||
configuredModel.supportedEffortLevels = supportedEffortLevels.length ? supportedEffortLevels : undefined;
|
||||
} else {
|
||||
models.unshift({
|
||||
name: modelId,
|
||||
label: aiSelectedModelInfo.value.displayName,
|
||||
supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return models.length ? models : undefined;
|
||||
}
|
||||
|
||||
function aiOnModelPopoverOpen(open: boolean) {
|
||||
if (!open) return;
|
||||
if (aiModelFetchSignature.value !== aiLastModelFetchSignature) {
|
||||
aiFetchedModels.value = [];
|
||||
if (aiCanListModels.value) void aiFetchModelList();
|
||||
} else if (aiFetchedModels.value.length === 0 && aiCanListModels.value) {
|
||||
void aiFetchModelList();
|
||||
}
|
||||
}
|
||||
|
||||
async function aiSaveConfig() {
|
||||
@@ -2682,13 +2464,10 @@ async function aiSaveConfig() {
|
||||
}
|
||||
|
||||
const editConfig = currentAiEditConfig();
|
||||
aiEditReasoningLevel.value = editConfig.reasoningLevel;
|
||||
const models = aiModelsForSave();
|
||||
const config: AiConfigItem = {
|
||||
id: aiEditConfigId.value || generateId(),
|
||||
name: aiEditConfigName.value,
|
||||
...editConfig,
|
||||
models,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2735,7 +2514,7 @@ async function aiSetDefaultConfig(id: string) {
|
||||
}
|
||||
|
||||
async function aiTestConn() {
|
||||
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim()) || (!aiIsCliProvider.value && !aiEditModel.value.trim())) return;
|
||||
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim())) return;
|
||||
if (aiCliValidationError.value) {
|
||||
aiTestResult.value = "error";
|
||||
aiTestError.value = aiCliValidationError.value;
|
||||
@@ -2747,9 +2526,16 @@ async function aiTestConn() {
|
||||
aiTestLatency.value = null;
|
||||
aiTestErrorCopied.value = false;
|
||||
try {
|
||||
const result = await aiTestConnection(currentAiEditConfig());
|
||||
const config = currentAiEditConfig();
|
||||
if (aiIsCliProvider.value) {
|
||||
const result = await aiTestConnection(config);
|
||||
aiTestLatency.value = result.latencyMs ?? null;
|
||||
} else {
|
||||
const startedAt = performance.now();
|
||||
await aiListModels(config);
|
||||
aiTestLatency.value = Math.round(performance.now() - startedAt);
|
||||
}
|
||||
aiTestResult.value = "success";
|
||||
aiTestLatency.value = result.latencyMs ?? null;
|
||||
} catch (e: any) {
|
||||
aiTestResult.value = "error";
|
||||
aiTestError.value = translateBackendError(t, e?.message || String(e));
|
||||
@@ -4828,7 +4614,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
<span class="text-sm font-medium">{{ config.name }}</span>
|
||||
<Badge v-if="config.isDefault" variant="default" class="h-5 text-[10px]"> {{ t("ai.default") }} </Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ AI_PROVIDER_PRESETS[config.provider].label }} - {{ config.model }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ AI_PROVIDER_PRESETS[config.provider].label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -5084,80 +4870,6 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.defaultModel") }}</Label>
|
||||
<div class="col-span-2">
|
||||
<Input v-model="aiEditModel" autocomplete="off" class="h-8 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<div class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.modelList") }}</Label>
|
||||
<div class="col-span-2 space-y-2">
|
||||
<div v-for="(m, index) in aiEditModels" :key="index" class="flex items-center gap-2">
|
||||
<Input v-model="m.name" :placeholder="t('ai.modelId')" class="h-8 flex-1 text-xs" />
|
||||
<Input v-model="m.label" :placeholder="t('ai.modelDisplayName')" class="h-8 flex-1 text-xs" />
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="aiRemoveModel(index)">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" @click="aiAddModel">
|
||||
<Plus class="mr-1 h-3 w-3" />
|
||||
{{ t("ai.add") }}
|
||||
</Button>
|
||||
<Popover v-model:open="aiModelMultiSelectOpen" @update:open="aiOnModelPopoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" :disabled="!aiCanListModels">
|
||||
<Loader2 v-if="aiModelListLoading" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3 w-3" />
|
||||
{{ aiModelListLoading ? t("ai.fetching") : t("ai.fetchModelList") }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-56 p-0" align="start">
|
||||
<div class="flex flex-col">
|
||||
<div class="border-b px-3 py-2">
|
||||
<Input v-model="aiModelMultiSelectSearch" :placeholder="t('ai.searchModels')" class="h-8 text-xs" />
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto p-1">
|
||||
<div v-if="aiFilteredFetchedModels.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{{ aiModelListLoading ? t("ai.loadingModels") : aiModelError || t("ai.noModels") }}
|
||||
</div>
|
||||
<button v-for="model in aiFilteredFetchedModels" :key="model.id" type="button" class="flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs hover:bg-muted" @click="aiToggleModel(model)">
|
||||
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="aiIsModelSelected(model.id) ? 'border-primary bg-primary text-primary-foreground' : ''">
|
||||
<Check v-if="aiIsModelSelected(model.id)" class="h-3 w-3" />
|
||||
</div>
|
||||
<span class="flex-1 truncate text-left">{{ model.displayName || model.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p v-if="aiModelError" class="text-xs text-destructive">{{ aiModelError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reasoning Level -->
|
||||
<div v-if="aiIsCodexCli || aiIsClaudeCodeCli" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Select v-model="aiEditReasoningLevel" :disabled="aiReasoningLevelDisabled">
|
||||
<SelectTrigger inputClass="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in aiReasoningLevelOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-[11px] text-muted-foreground">{{ aiReasoningLevelHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Style -->
|
||||
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API</Label>
|
||||
@@ -5168,25 +4880,6 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable Thinking -->
|
||||
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
|
||||
<div class="col-span-2 flex items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="aiEditEnableThinking" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" :disabled="!aiCompletionsMode || aiEditProvider === 'gemini'" />
|
||||
{{ aiEditEnableThinking ? t("ai.enableThinkingOn") : t("ai.enableThinkingOff") }}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="start">
|
||||
{{ t("ai.enableThinkingHint") }}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Window -->
|
||||
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
|
||||
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
|
||||
@@ -5691,7 +5384,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim()) || (!aiIsCliProvider && !aiEditModel?.trim())" @click="aiTestConn">
|
||||
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim())" @click="aiTestConn">
|
||||
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t("connection.test") }}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user