diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 8d0754f31e..03fb714285 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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([]); 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(); @@ -1326,32 +1337,60 @@ async function openExternalUrl(url: string) { @update:model-value="(value) => selectAction(value as AiAction)" /> - - - - + diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index b212bcbb66..703ab6fe9d 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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); - - - {{ provider.label }} + + + + {{ provider.label }} + + + {{ + t("ai.providerStatusActive") + }} + {{ t("ai.providerStatusConfigured") }} + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4f202b86c8..ad5713c73e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b0fcdf911e..5b6b26eabe 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 1f7c38e036..d890d7b811 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1176e544bb..726d298aa2 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1059,6 +1059,9 @@ export default withEnglishFallback({ settings: "AI設定", noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。", provider: "プロバイダー", + providerStatusActive: "使用中", + providerStatusConfigured: "設定済み", + switchProvider: "プロバイダーを切替", model: "モデル", browseModels: "モデルを参照", refreshModels: "モデルを更新", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1dbda6633f..1acc79fe27 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6385045581..55634c5150 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1076,6 +1076,9 @@ export default withEnglishFallback({ settings: "AI 设置", noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。", provider: "提供商", + providerStatusActive: "当前", + providerStatusConfigured: "已配置", + switchProvider: "切换服务商", model: "模型", browseModels: "浏览模型", refreshModels: "刷新模型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index da8d091488..263161f5ad 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1059,6 +1059,9 @@ export default withEnglishFallback({ settings: "AI 設定", noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。", provider: "供應商", + providerStatusActive: "目前", + providerStatusConfigured: "已設定", + switchProvider: "切換服務商", model: "模型", browseModels: "瀏覽模型", refreshModels: "重新整理模型", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index f4b5299a34..c04b25250b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index eea469078d..1bff529547 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -997,6 +997,14 @@ export async function saveAiConfig(config: AiConfig): Promise { return post("/api/ai/config", { config }); } +export async function saveAiProviderConfig(provider: string, config: AiConfig): Promise { + return post("/api/ai/provider-config", { provider, config }); +} + +export async function loadAiProviderConfigs(): Promise> { + return get("/api/ai/provider-configs"); +} + export async function loadAiConfig(): Promise { return get("/api/ai/config"); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index f0ecb59d49..b1e6b16c01 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -339,6 +339,14 @@ export async function saveAiConfig(config: AiConfig): Promise { return invoke("save_ai_config", { config }); } +export async function saveAiProviderConfig(provider: string, config: AiConfig): Promise { + return invoke("save_ai_provider_config", { provider, config }); +} + +export async function loadAiProviderConfigs(): Promise> { + return invoke("load_ai_provider_configs"); +} + export async function aiTestConnection(config: AiConfig): Promise { return invoke("ai_test_connection", { config }); } diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 67d9382f31..6df0ac3d71 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -738,6 +738,7 @@ function saveEditorSettings(settings: EditorSettings) { export const useSettingsStore = defineStore("settings", () => { const aiConfig = ref(normalizeAiConfig({ provider: "claude" })); const isAiConfigLoaded = ref(false); + const aiProviderConfigs = ref>>({}); const desktopSettings = ref({ ...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> | 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) { 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) { 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, diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index d25dd20602..a609c80f57 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -1,11 +1,12 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; +use log::warn; use rusqlite::{params, params_from_iter, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::ai::{AiChatMessage, AiConfig, AiConversation}; +use crate::ai::{AiChatMessage, AiConfig, AiConversation, AiProvider}; use crate::connection_secrets::{ MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, @@ -197,6 +198,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id INTEGER PRIMARY KEY CHECK (id = 1), config_json TEXT NOT NULL )", + "CREATE TABLE IF NOT EXISTS ai_provider_configs ( + provider TEXT PRIMARY KEY, + config_json TEXT NOT NULL + )", "CREATE TABLE IF NOT EXISTS ai_conversations ( id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', @@ -620,6 +625,15 @@ impl Storage { // AI Config +fn ai_provider_key(provider: &AiProvider) -> String { + serde_json::to_value(provider).ok().and_then(|value| value.as_str().map(ToOwned::to_owned)).unwrap_or_default() +} + +fn ai_provider_from_key(provider: &str) -> Result { + serde_json::from_value(serde_json::Value::String(provider.to_string())) + .map_err(|_| format!("Invalid AI provider: {provider}")) +} + impl Storage { pub async fn save_ai_config(&self, config: &AiConfig) -> Result<(), String> { let json = serde_json::to_string(config).map_err(|e| e.to_string())?; @@ -641,6 +655,65 @@ impl Storage { .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } + + pub async fn save_ai_provider_config(&self, provider: &str, config: &AiConfig) -> Result<(), String> { + let parsed_provider = ai_provider_from_key(provider)?; + let mut config = config.clone(); + let config_provider = ai_provider_key(&config.provider); + if config_provider != provider { + warn!( + "save_ai_provider_config: config.provider ({}) does not match provider key ({}), normalizing", + config_provider, provider + ); + config.provider = parsed_provider; + } + let provider = provider.to_string(); + let json = serde_json::to_string(&config).map_err(|e| e.to_string())?; + self.with_conn(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO ai_provider_configs (provider, config_json) VALUES (?1, ?2)", + params![provider, json], + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn load_ai_provider_configs(&self) -> Result, String> { + self.with_conn(|conn| { + let mut stmt = conn + .prepare("SELECT provider, config_json FROM ai_provider_configs") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(|e| e.to_string())?; + let mut map = HashMap::new(); + for row in rows { + let (provider, json) = row.map_err(|e| e.to_string())?; + match serde_json::from_str::(&json) { + Ok(mut config) => { + if let Ok(parsed_provider) = ai_provider_from_key(&provider) { + let config_provider = ai_provider_key(&config.provider); + if config_provider != provider { + warn!( + "load_ai_provider_configs: stored config.provider ({}) does not match provider key ({}), normalizing", + config_provider, provider + ); + config.provider = parsed_provider; + } + map.insert(provider, config); + } + } + Err(e) => { + warn!("Failed to deserialize AI config for provider '{}': {}", provider, e); + } + } + } + Ok(map) + }) + .await + } } // App Settings diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index fd255b1fad..4a8818f117 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -480,6 +480,8 @@ async fn main() { .route("/saved-sql/folders/{id}", delete(routes::saved_sql::delete_saved_sql_folder)) // AI .route("/ai/config", post(routes::ai::save_ai_config).get(routes::ai::load_ai_config)) + .route("/ai/provider-config", post(routes::ai::save_ai_provider_config)) + .route("/ai/provider-configs", get(routes::ai::load_ai_provider_configs)) .route("/ai/conversation", post(routes::ai::save_ai_conversation)) .route("/ai/conversations", get(routes::ai::load_ai_conversations)) .route("/ai/conversation/{id}", delete(routes::ai::delete_ai_conversation)) diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index eb5d1b8a2b..4799c2ed6e 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use axum::extract::{Path, State}; @@ -20,6 +21,13 @@ use crate::state::WebState; // Request types // --------------------------------------------------------------------------- +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveAiProviderConfigRequest { + pub provider: String, + pub config: AiConfig, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveAiConfigRequest { @@ -88,6 +96,11 @@ fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> Ok(()) } +fn ai_provider_from_key(provider: &str) -> Result { + serde_json::from_value(serde_json::Value::String(provider.to_string())) + .map_err(|_| AppError::bad_request(format!("Invalid AI provider: {provider}"))) +} + // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- @@ -106,6 +119,25 @@ pub async fn load_ai_config(State(state): State>) -> Result>, + Json(body): Json, +) -> Result, AppError> { + let parsed_provider = ai_provider_from_key(&body.provider)?; + let mut config = body.config; + config.provider = parsed_provider; + reject_web_unsupported_ai_provider(&config)?; + state.app.storage.save_ai_provider_config(&body.provider, &config).await.map_err(AppError)?; + Ok(Json(())) +} + +pub async fn load_ai_provider_configs( + State(state): State>, +) -> Result>, AppError> { + let configs = state.app.storage.load_ai_provider_configs().await.map_err(AppError)?; + Ok(Json(configs)) +} + // --------------------------------------------------------------------------- // Conversations // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index df007f63a5..917048dca0 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -27,6 +27,26 @@ pub async fn load_ai_config(state: State<'_, Arc>) -> Result>, + provider: String, + config: AiConfig, +) -> Result<(), String> { + let parsed_provider: AiProvider = serde_json::from_value(serde_json::Value::String(provider.clone())) + .map_err(|_| format!("Invalid AI provider: {provider}"))?; + let mut config = config; + config.provider = parsed_provider; + state.storage.save_ai_provider_config(&provider, &config).await +} + +#[tauri::command] +pub async fn load_ai_provider_configs( + state: State<'_, Arc>, +) -> Result, String> { + state.storage.load_ai_provider_configs().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 33f3b6d551..4450e49415 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -636,6 +636,8 @@ pub fn run() { commands::ai::ai_list_models, 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_conversation, commands::ai::load_ai_conversations, commands::ai::delete_ai_conversation,