feat(ai-config): persist AI settings per provider (#2412)

## 背景说明
AI 设置原本只有一个配置槽,切换模型厂商时会覆盖之前填写的 endpoint、API Key、model 等信息。本次改动让每个模型厂商各自保存一份配置,并在设置页和对话窗口提供已配置状态与快速切换能力。

## 修改内容
- 新增 ai_provider_configs 存储表,用于按 provider 保存独立 AI 配置
- 新增 Tauri/Web API:保存和加载 provider 级 AI 配置
- 设置页 provider 下拉展示“当前/已配置”状态
- 设置页切换 provider 前会先保存当前表单编辑内容,避免未保存输入丢失
- 对话窗口模型选择器支持查看并切换已配置过的其他 provider
- provider 配置保存时强制 config.provider 与 provider key 一致,避免错位配置污染
- 所有语言补齐新增 i18n 文案

## 修复的问题
- 切换模型厂商时原厂商配置会丢失
- 设置页切换 provider 时本地表单编辑态可能未保存
- provider key 与 config.provider 不一致时可能激活错误厂商
- 对话窗口模型选择器只能看到当前 provider 的模型,不能看到其他已配置厂商

## 测试
- npx tsc --noEmit
- cargo check -p dbx-core
- cargo check -p dbx-web
- cargo check --manifest-path src-tauri/Cargo.toml
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-02 14:24:57 +08:00
1 parent 486b9ee8ed
commit a0754206d3
18 files changed
+301 -51

No files matched your search

@@ -11,7 +11,8 @@ import LightDropdown from "@/components/ui/LightDropdown.vue";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSettingsStore, AI_PROVIDER_PRESETS, type AiProvider } from "@/stores/settingsStore";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIconType } from "@/lib/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@@ -90,6 +91,16 @@ const draftBeforeHistory = ref("");
const modelOptions = ref<AiModelInfo[]>([]);
const modelLoading = ref(false);
let modelRequestToken = 0;
const providerSelectorOpen = ref(false);
// Configured providers for quick switching
const configuredProviders = computed(() => (Object.keys(AI_PROVIDER_PRESETS) as AiProvider[]).filter((p) => p !== settings.aiConfig.provider && settings.isAiProviderConfigured(p)));
function handleProviderSwitch(provider: AiProvider) {
settings.updateAiConfig({ provider });
modelOptions.value = [];
providerSelectorOpen.value = false;
}
function normalizeModelOptions(models: AiModelInfo[]): AiModelInfo[] {
const seen = new Set<string>();
@@ -1326,32 +1337,60 @@ async function openExternalUrl(url: string) {
@update:model-value="(value) => selectAction(value as AiAction)"
/>
<span class="min-w-0 flex-1" />
<SearchableSelect
v-if="settings.isConfigured()"
:model-value="settings.aiConfig.model"
:options="modelOptionIds"
:placeholder="t('ai.browseModels')"
:search-placeholder="t('ai.searchModels')"
:empty-text="t('ai.modelListHint')"
:loading-text="t('ai.loadingModels')"
:loading="modelLoading"
:display-name="displayModelName"
trigger-class="min-w-0 w-auto max-w-[220px] shrink justify-end rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
content-class="w-72"
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
@update:model-value="handleModelSelect"
@update:open="(open: boolean) => open && fetchModelOptions()"
>
<template #trigger-label="{ label, loading }">
<span class="min-w-0 truncate">{{ loading ? t("ai.loadingModels") : label }}</span>
</template>
<template #option-label="{ option, label }">
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate">{{ modelOptionPresentation(option, label).primary }}</span>
<span v-if="modelOptionSecondary(option, label)" class="mt-0.5 truncate text-[11px] text-muted-foreground">{{ modelOptionSecondary(option, label) }}</span>
</span>
</template>
</SearchableSelect>
<template v-if="settings.isConfigured()">
<!-- Combined provider + model selector -->
<Popover v-model:open="providerSelectorOpen">
<PopoverTrigger as-child>
<button type="button" class="min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
<AiProviderLogo :provider="settings.aiConfig.provider" :label="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider" :icon-slug="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.iconSlug" class="h-3 w-3 shrink-0" />
<span class="min-w-0 truncate">{{ modelLoading ? t("ai.loadingModels") : settings.aiConfig.model }}</span>
<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-72 gap-0 p-1.5" @open-auto-focus.prevent>
<!-- Configured providers section -->
<template v-if="configuredProviders.length">
<p class="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{{ t("ai.switchProvider") }}</p>
<button v-for="p in configuredProviders" :key="p" 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" @click="handleProviderSwitch(p)">
<AiProviderLogo :provider="p" :label="AI_PROVIDER_PRESETS[p]?.label ?? p" :icon-slug="AI_PROVIDER_PRESETS[p]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
<span class="font-medium">{{ AI_PROVIDER_PRESETS[p]?.label ?? p }}</span>
<span class="ml-auto min-w-0 truncate text-[11px] text-muted-foreground">{{ settings.aiProviderConfigs[p]?.model }}</span>
</button>
<div class="my-1 border-t" />
</template>
<!-- Model list for current provider -->
<p class="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{{ AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider }}
</p>
<SearchableSelect
:model-value="settings.aiConfig.model"
:options="modelOptionIds"
:placeholder="t('ai.browseModels')"
:search-placeholder="t('ai.searchModels')"
:empty-text="t('ai.modelListHint')"
:loading-text="t('ai.loadingModels')"
:loading="modelLoading"
:display-name="displayModelName"
trigger-class="w-full max-w-full justify-start rounded-sm px-2 py-1.5 text-xs text-foreground hover:bg-accent"
content-class="w-72"
item-class="h-auto min-h-8 px-2 py-1.5 text-xs"
@update:model-value="handleModelSelect"
@update:open="(open: boolean) => open && fetchModelOptions()"
>
<template #trigger-label="{ label, loading }">
<AiProviderLogo :provider="settings.aiConfig.provider" :label="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.label ?? settings.aiConfig.provider" :icon-slug="AI_PROVIDER_PRESETS[settings.aiConfig.provider]?.iconSlug" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ loading ? t("ai.loadingModels") : label }}</span>
</template>
<template #option-label="{ option, label }">
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate">{{ modelOptionPresentation(option, label).primary }}</span>
<span v-if="modelOptionSecondary(option, label)" class="mt-0.5 truncate text-[11px] text-muted-foreground">{{ modelOptionSecondary(option, label) }}</span>
</span>
</template>
</SearchableSelect>
</PopoverContent>
</Popover>
</template>
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
<Square class="h-3.5 w-3.5" />
</button>
@@ -1746,20 +1746,15 @@ function syncAiEditState() {
function aiSelectProvider(provider: AiProvider) {
if (isWeb && provider === "codex-cli") return;
aiEditProvider.value = provider;
aiEditEndpoint.value = AI_PROVIDER_PRESETS[provider].endpoint;
aiEditModel.value = AI_PROVIDER_PRESETS[provider].model;
aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle;
aiEditAuthMethod.value = AI_PROVIDER_PRESETS[provider].authMethod;
aiEditReasoningLevel.value = "default";
if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = "";
aiEditCodexCliPath.value = "";
aiEditCodexCliEnvRows.value = [];
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
clearAiModelOptions();
if (provider === aiEditProvider.value) return;
// Save current form edits before switching to prevent data loss.
if (aiHasChanges()) {
settingsStore.updateAiConfig(currentAiEditConfig());
}
settingsStore.updateAiConfig({ provider });
syncAiEditState();
if (provider === "codex-cli") void ensureCodexMcpStatus();
}
@@ -3026,9 +3021,17 @@ onUnmounted(cleanupPreviewEditor);
</SelectTrigger>
<SelectContent>
<SelectItem v-for="provider in aiProviderOptions" :key="provider.provider" :value="provider.provider">
<span class="flex items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" />
<span>{{ provider.label }}</span>
<span class="flex w-full items-center justify-between gap-4">
<span class="flex items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" />
<span>{{ provider.label }}</span>
</span>
<span class="flex shrink-0 items-center gap-1">
<span v-if="aiEditProvider === provider.provider" class="rounded px-1 py-0.5 text-[10px] font-medium leading-none" :class="settingsStore.isAiProviderConfigured(provider.provider) ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">{{
t("ai.providerStatusActive")
}}</span>
<span v-else-if="settingsStore.isAiProviderConfigured(provider.provider)" class="rounded bg-green-500/15 px-1 py-0.5 text-[10px] font-medium leading-none text-green-700 dark:text-green-400">{{ t("ai.providerStatusConfigured") }}</span>
</span>
</span>
</SelectItem>
</SelectContent>