mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 08:08:02 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1059,6 +1059,9 @@ export default withEnglishFallback({
|
||||
settings: "AI設定",
|
||||
noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。",
|
||||
provider: "プロバイダー",
|
||||
providerStatusActive: "使用中",
|
||||
providerStatusConfigured: "設定済み",
|
||||
switchProvider: "プロバイダーを切替",
|
||||
model: "モデル",
|
||||
browseModels: "モデルを参照",
|
||||
refreshModels: "モデルを更新",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1076,6 +1076,9 @@ export default withEnglishFallback({
|
||||
settings: "AI 设置",
|
||||
noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。",
|
||||
provider: "提供商",
|
||||
providerStatusActive: "当前",
|
||||
providerStatusConfigured: "已配置",
|
||||
switchProvider: "切换服务商",
|
||||
model: "模型",
|
||||
browseModels: "浏览模型",
|
||||
refreshModels: "刷新模型",
|
||||
|
||||
@@ -1059,6 +1059,9 @@ export default withEnglishFallback({
|
||||
settings: "AI 設定",
|
||||
noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。",
|
||||
provider: "供應商",
|
||||
providerStatusActive: "目前",
|
||||
providerStatusConfigured: "已設定",
|
||||
switchProvider: "切換服務商",
|
||||
model: "模型",
|
||||
browseModels: "瀏覽模型",
|
||||
refreshModels: "重新整理模型",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user