diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index f7dde286c..df627d9a1 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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>(new Set()); +const effortMenuOpen = ref(false); +const manualModelConfigId = ref(""); +const manualModelId = ref(""); +const effortTextValue = ref(""); +const effortIntegerValue = ref(0); +let effortMenuCloseTimer: ReturnType | 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("[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) { + 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) { - - +
-
+
+ + + + + + +
+ + {{ t("ai.loadingEffort") }} +
+
+ {{ t("ai.effortLoadFailed") }} + +
+ + + +
+ + +
+
+ {{ t("ai.effortUnsupported") }} +
+
+
+
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 2e4bbceb2..e77607834 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(null); -// Model list management -const aiEditModels = ref([]); -const aiModelListLoading = ref(false); -let aiModelListRequestToken = 0; - -// AI Model Multi-Select -const aiModelMultiSelectOpen = ref(false); -const aiModelMultiSelectSearch = ref(""); -const aiFetchedModels = ref([]); - const CLI_AI_PROVIDERS = new Set(["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("api-key"); const aiEditEndpoint = ref(""); const aiEditModel = ref(""); +const aiEditLegacyModels = ref([]); const aiEditApiStyle = ref("completions"); const aiEditProxyEnabled = ref(false); const aiEditProxyUrl = ref(""); @@ -2229,24 +2216,7 @@ const aiEditCodexCliEnvRows = ref([]); const aiEditClaudeCodeCliPath = ref(""); const aiEditClaudeCodeCliEnvRows = ref([]); -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 = { - 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(); - 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(() => { - 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>(() => { - 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); {{ config.name }} {{ t("ai.default") }} -
{{ AI_PROVIDER_PRESETS[config.provider].label }} - {{ config.model }}
+
{{ AI_PROVIDER_PRESETS[config.provider].label }}
@@ -5084,80 +4870,6 @@ onUnmounted(cleanupPreviewEditor);
- -
- -
- -
-
- - -
- -
-
- - - -
-
- - - - - - -
-
- -
-
-
- {{ aiModelListLoading ? t("ai.loadingModels") : aiModelError || t("ai.noModels") }} -
- -
-
-
-
-
-

{{ aiModelError }}

-
-
- - -
- -
- -

{{ aiReasoningLevelHint }}

-
-
-
@@ -5168,25 +4880,6 @@ onUnmounted(cleanupPreviewEditor);
- -
- -
- - - - - - - {{ t("ai.enableThinkingHint") }} - - -
-
-
@@ -5691,7 +5384,7 @@ onUnmounted(cleanupPreviewEditor); ", source.indexOf("", selectorStart)); + const selector = source.slice(selectorStart, selectorEnd); + + assert.notEqual(selectorStart, -1, "the combined provider and model selector should exist"); + assert.match(selector, //); + assert.match(selector, //); + assert.match(selector, /@mouseenter="openEffortMenu"/); + assert.match(selector, /@mouseleave="scheduleEffortMenuClose"/); + assert.match(selector, / { const { descriptor, errors } = parse(source, { filename: aiAssistantPath }); assert.deepEqual(errors, []); diff --git a/packages/app-tests/aiConfigStore.test.ts b/packages/app-tests/aiConfigStore.test.ts index 0c0a73587..05709477a 100644 --- a/packages/app-tests/aiConfigStore.test.ts +++ b/packages/app-tests/aiConfigStore.test.ts @@ -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); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 67a55e3dd..861fd5b03 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -17,6 +17,12 @@ pub async fn ai_list_models(config: AiConfig) -> Result, String dbx_core::ai::list_models_core(&config).await } +#[tauri::command] +pub async fn ai_resolve_model_effort(config: AiConfig, model_id: String) -> Result { + 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>, 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>, + selection: AiChatSelectionState, +) -> Result<(), String> { + state.storage.save_ai_chat_selection(&selection).await +} + +#[tauri::command] +pub async fn load_ai_chat_selection(state: State<'_, Arc>) -> Result, String> { + state.storage.load_ai_chat_selection().await +} + #[tauri::command] pub async fn ai_complete(request: AiCompletionRequest) -> Result { dbx_core::ai::complete(&request).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf561294f..d73031d47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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,