diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index ef31b99ae4..d7a5f6837e 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2,11 +2,12 @@ import { ref, watch, shallowRef, computed } from "vue"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { useI18n } from "vue-i18n"; -import { CircleHelp, ExternalLink, Loader2, Settings } from "lucide-vue-next"; +import { CircleHelp, ExternalLink, Loader2, RefreshCw, Settings } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; @@ -22,7 +23,7 @@ import { } from "@/stores/settingsStore"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -import { aiTestConnection } from "@/lib/api"; +import { aiListModels, aiTestConnection, type AiModelInfo } from "@/lib/api"; import { eventToShortcut } from "@/lib/keyboardShortcuts"; import { SHORTCUT_DEFINITIONS, @@ -273,6 +274,12 @@ const aiEditProxyEnabled = ref(!!settingsStore.aiConfig.proxyEnabled); const aiEditProxyUrl = ref(settingsStore.aiConfig.proxyUrl || ""); const aiEditEnableThinking = ref(settingsStore.aiConfig.enableThinking ?? true); +const aiModelOptions = ref([]); +const aiModelLoading = ref(false); +const aiModelError = ref(""); +const aiModelLoadedSignature = ref(""); +let aiModelRequestToken = 0; + const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions"); const aiTesting = ref(false); @@ -285,6 +292,115 @@ const aiSupportsApiStyle = computed( aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom", ); +const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini"); +const aiCanListModels = computed( + () => + aiModelListSupported.value && + !!aiEditEndpoint.value.trim() && + (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()), +); +const aiModelOptionIds = computed(() => aiModelOptions.value.map((model) => model.id)); +const aiModelEmptyText = computed(() => { + if (aiModelError.value) return aiModelError.value; + if (!aiModelListSupported.value) return t("ai.modelListUnsupported"); + return t("ai.noModels"); +}); + +function clearAiModelOptions() { + aiModelRequestToken += 1; + aiModelOptions.value = []; + aiModelError.value = ""; + aiModelLoadedSignature.value = ""; + aiModelLoading.value = false; +} + +function aiModelConfigSignature() { + return JSON.stringify({ + provider: aiEditProvider.value, + endpoint: aiEditEndpoint.value.trim(), + apiKey: aiEditApiKey.value.trim(), + proxyEnabled: aiEditProxyEnabled.value, + proxyUrl: aiEditProxyUrl.value.trim(), + }); +} + +function currentAiEditConfig() { + return { + provider: aiEditProvider.value, + apiKey: aiEditApiKey.value, + endpoint: aiEditEndpoint.value, + model: aiEditModel.value, + apiStyle: aiEditApiStyle.value, + proxyEnabled: aiEditProxyEnabled.value, + proxyUrl: aiEditProxyUrl.value, + enableThinking: aiEditEnableThinking.value, + }; +} + +function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] { + const seen = new Set(); + const normalized: AiModelInfo[] = []; + for (const model of models) { + const id = model.id?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + normalized.push({ id, displayName: model.displayName?.trim() || undefined }); + } + return normalized; +} + +function displayAiModelName(modelId: string): string { + return aiModelOptions.value.find((model) => model.id === modelId)?.displayName || modelId; +} + +async function aiRefreshModels() { + if (aiModelLoading.value) return; + if (!aiModelListSupported.value) { + aiModelError.value = t("ai.modelListUnsupported"); + return; + } + if (!aiEditEndpoint.value.trim()) { + aiModelError.value = t("ai.modelListEndpointRequired"); + return; + } + if (aiRequiresApiKey.value && !aiEditApiKey.value.trim()) { + aiModelError.value = t("ai.modelListApiKeyRequired"); + return; + } + + const token = ++aiModelRequestToken; + const signature = aiModelConfigSignature(); + aiModelLoading.value = true; + aiModelError.value = ""; + try { + const models = normalizeAiModelOptions(await aiListModels(currentAiEditConfig())); + if (token !== aiModelRequestToken) return; + aiModelOptions.value = models; + aiModelLoadedSignature.value = signature; + if (!aiEditModel.value.trim() && models[0]) aiEditModel.value = models[0].id; + } catch (e: any) { + if (token !== aiModelRequestToken) return; + aiModelOptions.value = []; + aiModelError.value = e?.message || String(e); + } finally { + if (token === aiModelRequestToken) aiModelLoading.value = false; + } +} + +function onAiModelListOpen(open: boolean) { + if ( + open && + aiCanListModels.value && + !aiModelLoading.value && + (!aiModelOptions.value.length || aiModelLoadedSignature.value !== aiModelConfigSignature()) + ) { + void aiRefreshModels(); + } +} + +function aiSelectModel(modelId: string) { + aiEditModel.value = modelId; +} function syncAiEditState() { aiEditProvider.value = settingsStore.aiConfig.provider; @@ -297,6 +413,7 @@ function syncAiEditState() { aiEditEnableThinking.value = settingsStore.aiConfig.enableThinking ?? true; aiTestResult.value = ""; aiTestError.value = ""; + clearAiModelOptions(); } function aiSelectProvider(provider: AiProvider) { @@ -305,6 +422,7 @@ function aiSelectProvider(provider: AiProvider) { aiEditModel.value = AI_PROVIDER_PRESETS[provider].model; aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle; if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = ""; + clearAiModelOptions(); } function aiHasChanges(): boolean { @@ -321,34 +439,21 @@ function aiHasChanges(): boolean { } function aiApplySettings() { - settingsStore.updateAiConfig({ - provider: aiEditProvider.value, - apiKey: aiEditApiKey.value, - endpoint: aiEditEndpoint.value, - model: aiEditModel.value, - apiStyle: aiEditApiStyle.value, - proxyEnabled: aiEditProxyEnabled.value, - proxyUrl: aiEditProxyUrl.value, - enableThinking: aiEditEnableThinking.value, - }); + settingsStore.updateAiConfig(currentAiEditConfig()); } async function aiTestConn() { - if (!aiEditApiKey.value.trim() || !aiEditEndpoint.value.trim() || !aiEditModel.value.trim()) return; + if ( + (aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || + !aiEditEndpoint.value.trim() || + !aiEditModel.value.trim() + ) + return; aiTesting.value = true; aiTestResult.value = ""; aiTestError.value = ""; try { - await aiTestConnection({ - provider: aiEditProvider.value, - apiKey: aiEditApiKey.value, - endpoint: aiEditEndpoint.value, - model: aiEditModel.value, - apiStyle: aiEditApiStyle.value, - proxyEnabled: aiEditProxyEnabled.value, - proxyUrl: aiEditProxyUrl.value, - enableThinking: aiEditEnableThinking.value, - }); + await aiTestConnection(currentAiEditConfig()); aiTestResult.value = "success"; } catch (e: any) { aiTestResult.value = "error"; @@ -778,7 +883,6 @@ watch( -

{{ t("ai.settingsHint") }}

@@ -839,9 +943,56 @@ watch( /> -
- - +
+ +
+
+ + + + + + +
+

{{ aiModelError }}

+

+ {{ aiModelListSupported ? t("ai.modelListHint") : t("ai.modelListUnsupported") }} +

+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 26f3ed5fc1..7f74266561 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -559,6 +559,16 @@ export default { settings: "AI Settings", noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.", provider: "Provider", + model: "Model", + browseModels: "Browse models", + refreshModels: "Refresh models", + loadingModels: "Loading models...", + searchModels: "Search models", + noModels: "No models loaded", + modelListHint: "Click refresh to load models from the provider.", + modelListUnsupported: "This provider does not support fetching a model list.", + modelListEndpointRequired: "Enter an endpoint before loading models.", + modelListApiKeyRequired: "Enter an API key before loading models.", run: "Run", readingSchema: "Reading schema", noConnection: "No connection is available for this tab", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f94a0dab80..3545635c62 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -504,6 +504,16 @@ export default { placeholder: "Describe tu consulta en lenguaje natural...", settings: "Configuración de IA", provider: "Proveedor", + model: "Modelo", + browseModels: "Explorar modelos", + refreshModels: "Actualizar modelos", + loadingModels: "Cargando modelos...", + searchModels: "Buscar modelos", + noModels: "No hay modelos cargados", + modelListHint: "Pulsa actualizar para cargar los modelos del proveedor.", + modelListUnsupported: "Este proveedor no admite obtener una lista de modelos.", + modelListEndpointRequired: "Ingresa un endpoint antes de cargar modelos.", + modelListApiKeyRequired: "Ingresa una clave API antes de cargar modelos.", run: "Ejecutar", readingSchema: "Leyendo esquema", noConnection: "No hay una conexión disponible para esta pestaña", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 528b8f94b7..1ac9519f16 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -544,6 +544,16 @@ export default { settings: "AI 设置", noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。", provider: "提供商", + model: "模型", + browseModels: "浏览模型", + refreshModels: "刷新模型", + loadingModels: "加载模型中...", + searchModels: "搜索模型", + noModels: "暂无模型", + modelListHint: "点击刷新从服务端拉取模型列表。", + modelListUnsupported: "当前提供商不支持自动获取模型列表。", + modelListEndpointRequired: "填写 Endpoint 后再获取模型列表。", + modelListApiKeyRequired: "填写 API Key 后再获取模型列表。", run: "执行", readingSchema: "读取结构", noConnection: "当前标签页没有可用连接", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index c061a86a90..52d5c29525 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -92,6 +92,7 @@ export const aiComplete = forward("aiComplete"); export const aiStream = forward("aiStream"); export const aiCancelStream = forward("aiCancelStream"); export const aiTestConnection = forward("aiTestConnection"); +export const aiListModels = forward("aiListModels"); export const saveAiConfig = forward("saveAiConfig"); export const loadAiConfig = forward("loadAiConfig"); export const saveAiConversation = forward("saveAiConversation"); @@ -172,6 +173,7 @@ export type { AiMessage, AiCompletionRequest, AiStreamChunk, + AiModelInfo, AiChatMessage, AiConversation, AgentDriverInfo, diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 5a39fe1a04..71289da49c 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -24,6 +24,7 @@ import type { AiCompletionRequest, AiStreamChunk, AiConversation, + AiModelInfo, DriverInstallProgress, JavaRuntimeConfig, UpdateInfo, @@ -441,6 +442,10 @@ export async function aiTestConnection(config: AiConfig): Promise { return post("/api/ai/test-connection", { config }); } +export async function aiListModels(config: AiConfig): Promise { + return post("/api/ai/models", { config }); +} + export async function saveAiConfig(config: AiConfig): Promise { return post("/api/ai/config", { config }); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index e7d88ee3d0..580765f9a0 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -62,6 +62,11 @@ export interface AiCompletionRequest { temperature?: number; } +export interface AiModelInfo { + id: string; + displayName?: string; +} + export async function aiComplete(request: AiCompletionRequest): Promise { return invoke("ai_complete", { request }); } @@ -100,6 +105,10 @@ export async function aiTestConnection(config: AiConfig): Promise { return invoke("ai_test_connection", { config }); } +export async function aiListModels(config: AiConfig): Promise { + return invoke("ai_list_models", { config }); +} + export async function aiCancelStream(sessionId: string): Promise { return invoke("ai_cancel_stream", { sessionId }); } diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index adf1d8d20a..7ac4389317 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -2,7 +2,7 @@ use futures::StreamExt; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, LazyLock}; use tokio::sync::{Notify, RwLock}; @@ -135,6 +135,14 @@ pub struct AiConversation { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiModelInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- @@ -178,6 +186,29 @@ fn resolve_gemini_stream_endpoint(config: &AiConfig) -> String { } } +pub fn resolve_model_list_endpoint(config: &AiConfig) -> Result { + if matches!(config.provider, AiProvider::Gemini) { + return Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string()); + } + + let ep = config.endpoint.trim().trim_end_matches('/'); + if ep.is_empty() { + return Err("Endpoint is required".to_string()); + } + if ep.ends_with("/models") { + return Ok(ep.to_string()); + } + + let base = ep + .strip_suffix("/chat/completions") + .or_else(|| ep.strip_suffix("/responses")) + .or_else(|| ep.strip_suffix("/messages")) + .unwrap_or(ep) + .trim_end_matches('/'); + + Ok(format!("{base}/models")) +} + pub fn stream_data_payload(line: &str) -> Option<&str> { let line = line.trim(); if line.is_empty() || line.starts_with(':') || line.starts_with("event:") || line.starts_with("id:") { @@ -264,6 +295,13 @@ fn validate_config(config: &AiConfig) -> Result<(), String> { Ok(()) } +fn validate_model_list_config(config: &AiConfig) -> Result<(), String> { + if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() { + return Err("API key is required".to_string()); + } + resolve_model_list_endpoint(config).map(|_| ()) +} + fn maybe_bearer_headers(config: &AiConfig) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -276,6 +314,14 @@ fn maybe_bearer_headers(config: &AiConfig) -> Result { Ok(headers) } +fn claude_headers(config: &AiConfig) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert("x-api-key", HeaderValue::from_str(&config.api_key).map_err(|e| e.to_string())?); + headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); + Ok(headers) +} + pub fn build_ai_http_client(config: &AiConfig, timeout_secs: u64) -> Result { let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_secs)); if config.proxy_enabled && !config.proxy_url.trim().is_empty() { @@ -285,16 +331,96 @@ pub fn build_ai_http_client(config: &AiConfig, timeout_secs: u64) -> Result Result, String> { + let items = data["data"].as_array().ok_or_else(|| "Invalid model list response".to_string())?; + let mut seen = HashSet::new(); + let mut models = Vec::new(); + + for item in items { + let Some(id) = item["id"].as_str().filter(|id| !id.trim().is_empty()) else { + continue; + }; + if !seen.insert(id.to_string()) { + continue; + } + + let display_name = item["display_name"] + .as_str() + .or_else(|| item["name"].as_str()) + .filter(|name| !name.trim().is_empty() && *name != id) + .map(ToString::to_string); + + models.push(AiModelInfo { id: id.to_string(), display_name }); + } + + Ok(models) +} + +async fn list_claude_models(client: &reqwest::Client, config: &AiConfig) -> Result, String> { + let res = client + .get(resolve_model_list_endpoint(config)?) + .headers(claude_headers(config)?) + .send() + .await + .map_err(|e| format!("Claude model list request failed: {e}"))?; + + let status = res.status(); + let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(extract_error(&data).unwrap_or_else(|| format!("Claude model list API error: {status}"))); + } + + parse_model_list_response(&data) +} + +async fn list_openai_compatible_models( + client: &reqwest::Client, + config: &AiConfig, +) -> Result, String> { + let res = client + .get(resolve_model_list_endpoint(config)?) + .headers(maybe_bearer_headers(config)?) + .send() + .await + .map_err(|e| format!("AI model list request failed: {e}"))?; + + let status = res.status(); + let data: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(extract_error(&data).unwrap_or_else(|| format!("Model list API error: {status}"))); + } + + parse_model_list_response(&data) +} + +pub async fn list_models_core(config: &AiConfig) -> Result, String> { + validate_model_list_config(config)?; + + let client = build_ai_http_client(config, 30)?; + + match config.provider { + AiProvider::Claude => list_claude_models(&client, config).await, + AiProvider::Openai + | AiProvider::Deepseek + | AiProvider::Qwen + | AiProvider::Ollama + | AiProvider::OpenaiCompatible + | AiProvider::Custom => list_openai_compatible_models(&client, config).await, + AiProvider::Gemini => { + Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string()) + } + } +} + // --------------------------------------------------------------------------- // Non-streaming calls // --------------------------------------------------------------------------- pub async fn call_claude(client: &reqwest::Client, request: AiCompletionRequest) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert("x-api-key", HeaderValue::from_str(&request.config.api_key).map_err(|e| e.to_string())?); - headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); - let body = json!({ "model": request.config.model, "max_tokens": request.max_tokens.unwrap_or(2048), @@ -305,7 +431,7 @@ pub async fn call_claude(client: &reqwest::Client, request: AiCompletionRequest) let res = client .post(&resolve_endpoint(&request.config)) - .headers(headers) + .headers(claude_headers(&request.config)?) .json(&body) .send() .await @@ -532,11 +658,6 @@ async fn stream_claude( cancelled: &Notify, on_chunk: &impl Fn(AiStreamChunk), ) -> Result<(), String> { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert("x-api-key", HeaderValue::from_str(&request.config.api_key).map_err(|e| e.to_string())?); - headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); - let body = json!({ "model": request.config.model, "max_tokens": request.max_tokens.unwrap_or(2048), @@ -548,7 +669,7 @@ async fn stream_claude( let res = client .post(&resolve_endpoint(&request.config)) - .headers(headers) + .headers(claude_headers(&request.config)?) .json(&body) .send() .await @@ -920,7 +1041,8 @@ pub fn load_config(path: &Path) -> Result, String> { #[cfg(test)] mod tests { use super::{ - build_ai_http_client, gemini_text, resolve_endpoint, validate_config, AiApiStyle, AiConfig, AiProvider, + build_ai_http_client, gemini_text, parse_model_list_response, resolve_endpoint, resolve_model_list_endpoint, + validate_config, AiApiStyle, AiConfig, AiModelInfo, AiProvider, }; #[test] @@ -990,6 +1112,56 @@ mod tests { assert!(validate_config(&ollama).is_ok()); } + #[test] + fn resolves_model_list_endpoints_from_base_and_completion_urls() { + let openai = AiConfig { + provider: AiProvider::Openai, + api_key: "key".to_string(), + endpoint: "https://api.openai.com/v1/chat/completions".to_string(), + model: String::new(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + }; + assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); + + let claude = AiConfig { + provider: AiProvider::Claude, + api_key: "key".to_string(), + endpoint: "https://api.anthropic.com/v1/messages".to_string(), + model: String::new(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + }; + assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); + } + + #[test] + fn parses_openai_and_claude_model_list_items() { + let data = serde_json::json!({ + "data": [ + { "id": "gpt-4o-mini" }, + { "id": "claude-sonnet-4-20250514", "display_name": "Claude Sonnet 4" }, + { "id": "gpt-4o-mini" }, + { "display_name": "Missing ID" } + ] + }); + + assert_eq!( + parse_model_list_response(&data).unwrap(), + vec![ + AiModelInfo { id: "gpt-4o-mini".to_string(), display_name: None }, + AiModelInfo { + id: "claude-sonnet-4-20250514".to_string(), + display_name: Some("Claude Sonnet 4".to_string()) + }, + ] + ); + } + #[test] fn parses_gemini_text_and_provider_aliases() { let data = serde_json::json!({ diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 91072b3d40..19ccbf140d 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -169,6 +169,7 @@ async fn main() { .route("/ai/stream", post(routes::ai::ai_stream)) .route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream)) .route("/ai/test-connection", post(routes::ai::ai_test_connection)) + .route("/ai/models", post(routes::ai::ai_list_models)) // Transfer .route("/transfer/start", post(routes::transfer::start_transfer)) .route("/transfer/progress/{transferId}", get(routes::transfer::transfer_progress)) diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 4050571115..5a0ba87de5 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -6,7 +6,7 @@ use axum::Json; use futures::stream::Stream; use serde::Deserialize; -use dbx_core::ai::{AiCompletionRequest, AiConfig, AiConversation, AiStreamChunk}; +use dbx_core::ai::{AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiStreamChunk}; use crate::error::AppError; use crate::state::WebState; @@ -46,6 +46,12 @@ pub struct AiTestConnectionRequest { pub config: AiConfig, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiListModelsRequest { + pub config: AiConfig, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiCancelStreamRequest { @@ -112,6 +118,11 @@ pub async fn ai_test_connection(Json(body): Json) -> Re Ok(Json(result)) } +pub async fn ai_list_models(Json(body): Json) -> Result>, AppError> { + let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError)?; + Ok(Json(result)) +} + // --------------------------------------------------------------------------- // AI cancel stream // --------------------------------------------------------------------------- diff --git a/packages/app-tests/settingsDialogLayout.test.ts b/packages/app-tests/settingsDialogLayout.test.ts index 3cefa82e40..5d3d97da1d 100644 --- a/packages/app-tests/settingsDialogLayout.test.ts +++ b/packages/app-tests/settingsDialogLayout.test.ts @@ -43,3 +43,10 @@ test("settings dialog exposes sidebar activation in navigation settings", () => assert.match(source, /activeSettingsTab === ['"]navigation['"]/); assert.match(source, /settings\.sidebarActivation/); }); + +test("AI settings can browse provider model names while keeping manual input", () => { + assert.match(source, /aiListModels/); + assert.match(source, / { assert.match(httpSource, new RegExp(`export async function ${name}\\b`)); assert.match(tauriSource, new RegExp(`export async function ${name}\\b`)); } + assert.match(apiSource, /export const aiListModels = forward\("aiListModels"\)/); + assert.match(httpSource, /export async function aiListModels\b/); + assert.match(tauriSource, /export async function aiListModels\b/); }); test("web backend exposes agent driver management routes", () => { @@ -36,4 +39,5 @@ test("web backend exposes agent driver management routes", () => { assert.match(webMainSource, /\/agents\/install/); assert.match(webMainSource, /\/agents\/progress\/\{operationId\}/); assert.match(webMainSource, /\/agents\/java-runtime/); + assert.match(webMainSource, /\/ai\/models/); }); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 5a42eba899..9439eb9131 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -9,6 +9,11 @@ pub async fn ai_test_connection(config: AiConfig) -> Result { dbx_core::ai::test_connection_core(&config).await } +#[tauri::command] +pub async fn ai_list_models(config: AiConfig) -> Result, String> { + dbx_core::ai::list_models_core(&config).await +} + #[tauri::command] pub async fn save_ai_config(state: State<'_, Arc>, config: AiConfig) -> Result<(), String> { state.storage.save_ai_config(&config).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ebdcc63f45..b63222b27b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -175,6 +175,7 @@ pub fn run() { commands::ai::ai_stream, commands::ai::ai_cancel_stream, commands::ai::ai_test_connection, + commands::ai::ai_list_models, commands::ai::save_ai_config, commands::ai::load_ai_config, commands::ai::save_ai_conversation,