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:
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>
|
||||
|
||||
Reference in new issue
Block a user