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
2026-07-02 14:24:57 +08:00
committed by GitHub
parent 486b9ee8ed
commit a0754206d3
18 changed files with 301 additions and 51 deletions
@@ -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>
+3
View File
@@ -1073,6 +1073,9 @@ export default {
settings: "AI Settings",
noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.",
provider: "Provider",
providerStatusActive: "Active",
providerStatusConfigured: "Configured",
switchProvider: "Switch Provider",
model: "Model",
browseModels: "Browse models",
refreshModels: "Refresh models",
+3
View File
@@ -1058,6 +1058,9 @@ export default withEnglishFallback({
settings: "Configuración de IA",
noConfig: "La IA no está configurada. Configura tu proveedor de IA en Ajustes > IA.",
provider: "Proveedor",
providerStatusActive: "Activo",
providerStatusConfigured: "Configurado",
switchProvider: "Cambiar proveedor",
model: "Modelo",
browseModels: "Explorar modelos",
refreshModels: "Actualizar modelos",
+3
View File
@@ -1074,6 +1074,9 @@ export default withEnglishFallback({
settings: "Impostazioni AI",
noConfig: "L'AI non è configurata. Configura il tuo provider AI in Impostazioni > AI.",
provider: "Provider",
providerStatusActive: "Attivo",
providerStatusConfigured: "Configurato",
switchProvider: "Cambia provider",
model: "Modello",
browseModels: "Sfoglia modelli",
refreshModels: "Aggiorna modelli",
+3
View File
@@ -1059,6 +1059,9 @@ export default withEnglishFallback({
settings: "AI設定",
noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。",
provider: "プロバイダー",
providerStatusActive: "使用中",
providerStatusConfigured: "設定済み",
switchProvider: "プロバイダーを切替",
model: "モデル",
browseModels: "モデルを参照",
refreshModels: "モデルを更新",
+3
View File
@@ -1070,6 +1070,9 @@ export default withEnglishFallback({
settings: "Configurações de AI",
noConfig: "A AI não está configurada. Configure seu provedor de AI em Configurações > AI.",
provider: "Provedor",
providerStatusActive: "Ativo",
providerStatusConfigured: "Configurado",
switchProvider: "Trocar provedor",
model: "Modelo",
browseModels: "Explorar modelos",
refreshModels: "Atualizar modelos",
+3
View File
@@ -1076,6 +1076,9 @@ export default withEnglishFallback({
settings: "AI 设置",
noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。",
provider: "提供商",
providerStatusActive: "当前",
providerStatusConfigured: "已配置",
switchProvider: "切换服务商",
model: "模型",
browseModels: "浏览模型",
refreshModels: "刷新模型",
+3
View File
@@ -1059,6 +1059,9 @@ export default withEnglishFallback({
settings: "AI 設定",
noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。",
provider: "供應商",
providerStatusActive: "目前",
providerStatusConfigured: "已設定",
switchProvider: "切換服務商",
model: "模型",
browseModels: "瀏覽模型",
refreshModels: "重新整理模型",
+2
View File
@@ -208,6 +208,8 @@ export const aiTestConnection = forward("aiTestConnection");
export const aiListModels = forward("aiListModels");
export const saveAiConfig = forward("saveAiConfig");
export const loadAiConfig = forward("loadAiConfig");
export const saveAiProviderConfig = forward("saveAiProviderConfig");
export const loadAiProviderConfigs = forward("loadAiProviderConfigs");
export const loadDesktopSettings = forward("loadDesktopSettings");
export const saveDesktopSettings = forward("saveDesktopSettings");
export const setDriverStoreDir = forward("setDriverStoreDir");
+8
View File
@@ -997,6 +997,14 @@ export async function saveAiConfig(config: AiConfig): Promise<void> {
return post("/api/ai/config", { config });
}
export async function saveAiProviderConfig(provider: string, config: AiConfig): Promise<void> {
return post("/api/ai/provider-config", { provider, config });
}
export async function loadAiProviderConfigs(): Promise<Record<string, AiConfig>> {
return get("/api/ai/provider-configs");
}
export async function loadAiConfig(): Promise<AiConfig | null> {
return get("/api/ai/config");
}
+8
View File
@@ -339,6 +339,14 @@ export async function saveAiConfig(config: AiConfig): Promise<void> {
return invoke("save_ai_config", { config });
}
export async function saveAiProviderConfig(provider: string, config: AiConfig): Promise<void> {
return invoke("save_ai_provider_config", { provider, config });
}
export async function loadAiProviderConfigs(): Promise<Record<string, AiConfig>> {
return invoke("load_ai_provider_configs");
}
export async function aiTestConnection(config: AiConfig): Promise<AiTestConnectionResult> {
return invoke("ai_test_connection", { config });
}
+46 -6
View File
@@ -738,6 +738,7 @@ function saveEditorSettings(settings: EditorSettings) {
export const useSettingsStore = defineStore("settings", () => {
const aiConfig = ref<AiConfig>(normalizeAiConfig({ provider: "claude" }));
const isAiConfigLoaded = ref(false);
const aiProviderConfigs = ref<Partial<Record<AiProvider, AiConfig>>>({});
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
const isDesktopSettingsLoaded = ref(false);
@@ -770,24 +771,53 @@ export const useSettingsStore = defineStore("settings", () => {
async function initAiConfig() {
if (isAiConfigLoaded.value) return;
const legacy = localStorage.getItem("dbx-ai-config");
const saved = await api.loadAiConfig().catch(() => null);
if (saved) {
aiConfig.value = normalizeAiConfig(saved);
const [savedActive, savedProviderConfigs] = await Promise.all([api.loadAiConfig().catch(() => null), api.loadAiProviderConfigs().catch(() => null) as Promise<Partial<Record<AiProvider, AiConfig>> | null>]);
if (savedActive) {
aiConfig.value = normalizeAiConfig(savedActive);
} else if (legacy) {
aiConfig.value = normalizeAiConfig(JSON.parse(legacy));
await api.saveAiConfig(aiConfig.value).catch(() => {});
localStorage.removeItem("dbx-ai-config");
}
if (savedProviderConfigs) {
aiProviderConfigs.value = savedProviderConfigs;
}
// Ensure active config is reflected in provider map (overwrite if exists, to guarantee consistency)
const activeProvider = aiConfig.value.provider;
aiProviderConfigs.value[activeProvider] = { ...aiConfig.value };
isAiConfigLoaded.value = true;
}
function updateAiConfig(config: Partial<AiConfig>) {
const previousProvider = aiConfig.value.provider;
if (config.provider && config.provider !== previousProvider) {
Object.assign(aiConfig.value, defaultConfigs[config.provider]);
const targetProvider = config.provider;
const switchingProvider = !!targetProvider && targetProvider !== previousProvider;
if (switchingProvider) {
// Save current provider's config before switching
aiProviderConfigs.value[previousProvider] = { ...aiConfig.value };
api.saveAiProviderConfig(previousProvider, aiConfig.value).catch(() => {});
// Restore saved config for target provider, or use preset defaults
const savedConfig = aiProviderConfigs.value[targetProvider];
if (savedConfig) {
aiConfig.value = normalizeAiConfig({ ...savedConfig, provider: targetProvider });
} else {
aiConfig.value = normalizeAiConfig({ provider: targetProvider });
}
// Don't merge caller fields when switching — caller passes only { provider }
} else {
// Not switching provider — apply partial update
Object.assign(aiConfig.value, config);
}
Object.assign(aiConfig.value, config);
aiProviderConfigs.value[aiConfig.value.provider] = { ...aiConfig.value };
api.saveAiConfig(aiConfig.value).catch(() => {});
api.saveAiProviderConfig(aiConfig.value.provider, aiConfig.value).catch(() => {});
}
function isConfigured(): boolean {
@@ -796,6 +826,14 @@ export const useSettingsStore = defineStore("settings", () => {
return !!aiConfig.value.endpoint && !!aiConfig.value.model && (!preset.requiresApiKey || !!aiConfig.value.apiKey);
}
function isAiProviderConfigured(provider: AiProvider): boolean {
const config = aiProviderConfigs.value[provider];
if (!config) return false;
if (provider === "codex-cli") return true;
const preset = AI_PROVIDER_PRESETS[provider];
return !!config.endpoint?.trim() && !!config.model?.trim() && (!preset.requiresApiKey || !!config.apiKey?.trim());
}
function updateEditorSettings(partial: Partial<EditorSettings>) {
if (partial.fontFamily !== undefined) editorSettings.value.fontFamily = partial.fontFamily;
if (partial.fontSize !== undefined) editorSettings.value.fontSize = partial.fontSize;
@@ -911,9 +949,11 @@ export const useSettingsStore = defineStore("settings", () => {
return {
aiConfig,
isAiConfigLoaded,
aiProviderConfigs,
initAiConfig,
updateAiConfig,
isConfigured,
isAiProviderConfigured,
editorSettings,
desktopSettings,
updateEditorSettings,