diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index eb36ac4..1911563 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -24,4 +24,5 @@ tauri-plugin-global-shortcut = { workspace = true } log = { workspace = true } rusqlite = { workspace = true } chrono = "0.4" -tracing-subscriber = { workspace = true } \ No newline at end of file +tracing-subscriber = { workspace = true } +futures = { workspace = true } \ No newline at end of file diff --git a/apps/desktop/frontend/index.html b/apps/desktop/frontend/index.html index 38adea1..62474f9 100644 --- a/apps/desktop/frontend/index.html +++ b/apps/desktop/frontend/index.html @@ -403,6 +403,36 @@ } .field-error { color: var(--danger); } .field-warning { color: var(--warning); } + .connection-test-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + margin-top: 2px; + } + .connection-test-copy { + min-width: 0; + flex: 1; + } + .connection-test-status { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + line-height: 1.45; + } + .connection-test-status.success { color: var(--success); } + .connection-test-status.error { color: var(--danger); } + .connection-test-status.testing { color: var(--warning); } + .connection-test-time { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + line-height: 1.45; + } input[aria-invalid="true"], textarea[aria-invalid="true"], select[aria-invalid="true"] { @@ -1197,6 +1227,13 @@

+
+
+
+
+
+ +
@@ -1250,6 +1287,13 @@

+
+
+
+
+
+ +
@@ -1440,6 +1484,7 @@

}); // Refresh data when switching to these views if (view === 'history') loadHistory(); + if (view === 'settings') refreshConnectionTestTimes(); if (view === 'about') loadSystemInfo(); } @@ -1600,6 +1645,7 @@

document.getElementById('asr-api-key').value = connection.api_key || ''; document.getElementById('asr-model').value = connection.model || ''; document.getElementById('asr-language').value = connection.language || ''; + loadConnectionTestStatus('asr'); } function loadLlmConnectionFields(connection) { @@ -1609,6 +1655,7 @@

document.getElementById('llm-endpoint').value = connection.endpoint || ''; document.getElementById('llm-api-key').value = connection.api_key || ''; document.getElementById('llm-model').value = connection.model || ''; + loadConnectionTestStatus('llm'); } function saveAsrConnectionFields(connectionId) { @@ -1659,6 +1706,178 @@

return !asrEndpointInvalid && !llmEndpointInvalid; } + function getConnectionTestConnection(kind) { + return kind === 'asr' ? getActiveAsrConnection() : getActiveLlmConnection(); + } + + function getConnectionTestId(kind) { + const connection = getConnectionTestConnection(kind); + return connection?.id || 'default'; + } + + function getAsrConnectionDraft() { + return { + connection_id: getConnectionTestId('asr'), + provider: document.getElementById('asr-provider').value || 'mock', + endpoint: document.getElementById('asr-endpoint').value.trim() || null, + api_key: document.getElementById('asr-api-key').value.trim() || null, + model: document.getElementById('asr-model').value.trim() || null, + language: document.getElementById('asr-language').value.trim() || null, + }; + } + + function getLlmConnectionDraft() { + return { + connection_id: getConnectionTestId('llm'), + provider: document.getElementById('llm-provider').value || 'mock', + endpoint: document.getElementById('llm-endpoint').value.trim() || null, + api_key: document.getElementById('llm-api-key').value.trim() || null, + model: document.getElementById('llm-model').value.trim() || null, + language: null, + }; + } + + function formatRelativeTestTime(timestamp) { + if (!timestamp) return typexI18n.t('settings.connection_not_tested'); + const time = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp); + if (Number.isNaN(time)) return typexI18n.t('settings.connection_not_tested'); + const elapsedMs = Date.now() - time; + if (!Number.isFinite(elapsedMs) || elapsedMs < 60_000) { + return typexI18n.t('settings.connection_last_tested_just_now'); + } + const minutes = Math.floor(elapsedMs / 60_000); + if (minutes < 60) { + const key = minutes === 1 + ? 'settings.connection_last_tested_minute_ago' + : 'settings.connection_last_tested_minutes_ago'; + return typexI18n.t(key).replace('{0}', minutes); + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const key = hours === 1 + ? 'settings.connection_last_tested_hour_ago' + : 'settings.connection_last_tested_hours_ago'; + return typexI18n.t(key).replace('{0}', hours); + } + const date = new Date(time); + return typexI18n.t('settings.connection_last_tested_at').replace('{0}', date.toLocaleString()); + } + + function setConnectionTestStatus(kind, state, message, timestamp, messageKey = '') { + const statusEl = document.getElementById(`${kind}-test-status`); + const timeEl = document.getElementById(`${kind}-test-time`); + const button = document.getElementById(`${kind}-test-connection`); + if (!statusEl || !timeEl || !button) return; + + statusEl.classList.remove('success', 'error', 'testing'); + if (state) statusEl.classList.add(state); + statusEl.dataset.messageKey = messageKey || ''; + statusEl.dataset.fallbackMessage = message || ''; + statusEl.textContent = messageKey + ? typexI18n.t(messageKey) + : (message || typexI18n.t('settings.connection_not_tested')); + timeEl.dataset.timestamp = timestamp || ''; + timeEl.textContent = formatRelativeTestTime(timestamp); + button.disabled = state === 'testing'; + button.textContent = state === 'testing' + ? typexI18n.t('settings.connection_testing_short') + : typexI18n.t('settings.test_connection'); + } + + async function loadConnectionTestStatus(kind) { + try { + const status = await invoke('get_provider_connection_status', { + kind, + connectionId: getConnectionTestId(kind), + }); + renderConnectionTestStatus(kind, status); + } catch (e) { + console.warn('Failed to load connection test status:', e); + setConnectionTestStatus(kind, null, typexI18n.t('settings.connection_not_tested'), null); + } + } + + function connectionTestMessage(result) { + if (result?.message_key) return typexI18n.t(result.message_key); + if (result?.ok) return typexI18n.t('settings.connection_success'); + return result?.message || typexI18n.t('settings.connection_failed'); + } + + function renderConnectionTestStatus(kind, status) { + if (!status) { + setConnectionTestStatus(kind, null, typexI18n.t('settings.connection_not_tested'), null); + return; + } + setConnectionTestStatus( + kind, + status.ok ? 'success' : 'error', + connectionTestMessage(status), + status.tested_at, + status.message_key || '' + ); + } + + function refreshConnectionTestTimes() { + ['asr', 'llm'].forEach(kind => { + const statusEl = document.getElementById(`${kind}-test-status`); + const timeEl = document.getElementById(`${kind}-test-time`); + const button = document.getElementById(`${kind}-test-connection`); + if (!statusEl || !timeEl || !button) return; + + const messageKey = statusEl.dataset.messageKey || ''; + const fallbackMessage = statusEl.dataset.fallbackMessage || ''; + statusEl.textContent = messageKey + ? typexI18n.t(messageKey) + : (fallbackMessage || typexI18n.t('settings.connection_not_tested')); + timeEl.textContent = formatRelativeTestTime(timeEl.dataset.timestamp || null); + if (!button.disabled) button.textContent = typexI18n.t('settings.test_connection'); + }); + } + + async function testAsrConnection() { + if (!validateSettings()) return; + setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString()); + const saved = await saveConfig({ reloadConnectionStatus: false }); + if (!saved) { + await loadConnectionTestStatus('asr'); + return; + } + setConnectionTestStatus('asr', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString()); + try { + const result = await invoke('test_asr_connection', { request: getAsrConnectionDraft() }); + renderConnectionTestStatus('asr', result); + } catch (e) { + renderConnectionTestStatus('asr', { + ok: false, + provider: document.getElementById('asr-provider').value || 'mock', + message: String(e || typexI18n.t('settings.connection_failed')), + tested_at: new Date().toISOString(), + }); + } + } + + async function testLlmConnection() { + if (!validateSettings()) return; + setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString()); + const saved = await saveConfig({ reloadConnectionStatus: false }); + if (!saved) { + await loadConnectionTestStatus('llm'); + return; + } + setConnectionTestStatus('llm', 'testing', typexI18n.t('settings.connection_testing'), new Date().toISOString()); + try { + const result = await invoke('test_llm_connection', { request: getLlmConnectionDraft() }); + renderConnectionTestStatus('llm', result); + } catch (e) { + renderConnectionTestStatus('llm', { + ok: false, + provider: document.getElementById('llm-provider').value || 'mock', + message: String(e || typexI18n.t('settings.connection_failed')), + tested_at: new Date().toISOString(), + }); + } + } + function applyTheme(theme) { const value = ['system', 'light', 'dark'].includes(theme) ? theme : 'system'; document.documentElement.dataset.theme = value; @@ -1737,9 +1956,11 @@

applyDynamicLabels(); setSaveStatus(null, 'settings.save_idle'); validateSettings(); + refreshConnectionTestTimes(); } - async function saveConfig() { + async function saveConfig(options = {}) { + const reloadConnectionStatus = options.reloadConnectionStatus !== false; if (!currentConfig) return false; if (!validateSettings()) { setSaveStatus('error', 'settings.save_invalid'); @@ -1771,6 +1992,10 @@

try { await invoke('save_config', { config: currentConfig }); + if (reloadConnectionStatus) { + loadConnectionTestStatus('asr'); + loadConnectionTestStatus('llm'); + } if (seq === saveSeq) setSaveStatus('saved', 'settings.save_saved'); return true; } catch (e) { @@ -1792,6 +2017,7 @@

// loadConfig will also call applyAll, but we want immediate UI refresh if (currentConfig) currentConfig.ui.language = newLang; applyDynamicLabels(); + refreshConnectionTestTimes(); if (seq === saveSeq) setSaveStatus('saved', 'settings.save_saved'); } catch (e) { console.error('Failed to set language:', e); @@ -1883,6 +2109,10 @@

saveConfig(); }); + document.getElementById('asr-test-connection').addEventListener('click', testAsrConnection); + document.getElementById('llm-test-connection').addEventListener('click', testLlmConnection); + setInterval(refreshConnectionTestTimes, 60_000); + // Auto-save on input for text/password/number/textarea fields document.querySelectorAll('#view-settings input[type="text"], #view-settings input[type="password"], #view-settings input[type="number"], #view-settings textarea').forEach(el => { el.addEventListener('input', () => { diff --git a/apps/desktop/frontend/locales/en.json b/apps/desktop/frontend/locales/en.json index a581bd4..a8d7352 100644 --- a/apps/desktop/frontend/locales/en.json +++ b/apps/desktop/frontend/locales/en.json @@ -90,6 +90,20 @@ "settings.advanced_desc": "System prompt, plugin pipeline, and experimental performance options.", "settings.llm_provider_settings": "AI Provider Settings", "settings.llm_disabled_advanced": "Enable AI Polish above to configure provider, endpoint, model, and prompt.", + "settings.test_connection": "Test", + "settings.connection_testing": "Testing connection...", + "settings.connection_testing_short": "Testing...", + "settings.connection_success": "Connection OK", + "settings.connection_failed": "Connection failed", + "settings.mock_asr_ok": "Mock ASR is available.", + "settings.mock_llm_ok": "Mock LLM is available.", + "settings.connection_not_tested": "Not tested yet", + "settings.connection_last_tested_just_now": "Last tested just now", + "settings.connection_last_tested_minute_ago": "Last tested 1 minute ago", + "settings.connection_last_tested_minutes_ago": "Last tested {0} minutes ago", + "settings.connection_last_tested_hour_ago": "Last tested 1 hour ago", + "settings.connection_last_tested_hours_ago": "Last tested {0} hours ago", + "settings.connection_last_tested_at": "Last tested at {0}", "aria.navigation": "Primary navigation", "aria.minimize": "Minimize window", "aria.close": "Close window", diff --git a/apps/desktop/frontend/locales/zh-CN.json b/apps/desktop/frontend/locales/zh-CN.json index 94eeb10..3c35ca2 100644 --- a/apps/desktop/frontend/locales/zh-CN.json +++ b/apps/desktop/frontend/locales/zh-CN.json @@ -90,6 +90,20 @@ "settings.advanced_desc": "系统 Prompt、插件流水线和实验性性能选项。", "settings.llm_provider_settings": "AI Provider 设置", "settings.llm_disabled_advanced": "先在上方启用 AI 润色,再配置 provider、endpoint、model 和 prompt。", + "settings.test_connection": "测试", + "settings.connection_testing": "正在测试连接...", + "settings.connection_testing_short": "测试中...", + "settings.connection_success": "连接正常", + "settings.connection_failed": "连接失败", + "settings.mock_asr_ok": "Mock ASR 可用。", + "settings.mock_llm_ok": "Mock LLM 可用。", + "settings.connection_not_tested": "尚未测试", + "settings.connection_last_tested_just_now": "上次测试:刚刚", + "settings.connection_last_tested_minute_ago": "上次测试:1 分钟前", + "settings.connection_last_tested_minutes_ago": "上次测试:{0} 分钟前", + "settings.connection_last_tested_hour_ago": "上次测试:1 小时前", + "settings.connection_last_tested_hours_ago": "上次测试:{0} 小时前", + "settings.connection_last_tested_at": "上次测试:{0}", "aria.navigation": "主导航", "aria.minimize": "最小化窗口", "aria.close": "关闭窗口", diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index aa81e01..dc05847 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -1,8 +1,13 @@ -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, +use std::{ + collections::BTreeMap, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, }; +use futures::{StreamExt, stream}; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use tauri::{ @@ -11,7 +16,9 @@ use tauri::{ tray::TrayIconBuilder, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; -use typex_config::AppConfig; +use typex_config::{AppConfig, AsrConnection, LlmConnection}; +use typex_core::typex_asr::AsrProvider; +use typex_core::typex_llm::LlmProvider; use typex_core::{TypeX, TypeXBuildOptions, build_typex_from_config}; /// The spawn_blocking task returns this handle once the recording stop signal is received. @@ -22,6 +29,8 @@ type RecordingAccFuture = struct AppState { config: Mutex, config_path: std::path::PathBuf, + provider_status_path: std::path::PathBuf, + provider_status: Mutex, db: Mutex, pipeline: Mutex>, capture: Mutex>, @@ -46,6 +55,13 @@ fn config_path(app: &tauri::AppHandle) -> std::path::PathBuf { .join("config.toml") } +fn provider_status_path(app: &tauri::AppHandle) -> std::path::PathBuf { + app.path() + .app_config_dir() + .expect("failed to resolve app config dir") + .join("provider_status.json") +} + fn db_path(app: &tauri::AppHandle) -> std::path::PathBuf { app.path() .app_config_dir() @@ -85,6 +101,241 @@ struct HistoryEntry { pinned: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct ProviderStatusStore { + #[serde(default)] + asr: BTreeMap, + #[serde(default)] + llm: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProviderConnectionStatus { + tested_at: String, + ok: bool, + provider: String, + #[serde(default)] + message: Option, + #[serde(default)] + message_key: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConnectionTestRequest { + #[serde(default)] + connection_id: Option, + provider: String, + endpoint: Option, + api_key: Option, + model: Option, + language: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct ConnectionTestResult { + ok: bool, + message: String, + provider: String, + tested_at: Option, + message_key: Option, +} + +fn trimmed_option(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn api_key_or_env(value: Option) -> Option { + trimmed_option(value).or_else(|| { + std::env::var("OPENAI_API_KEY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn sanitize_connection_error(error: impl std::fmt::Display) -> String { + let mut message = error.to_string(); + if message.len() > 500 { + let mut limit = 500; + while !message.is_char_boundary(limit) { + limit -= 1; + } + message.truncate(limit); + message.push('…'); + } + format!("Connection failed: {}", message) +} + +fn connection_test_result( + provider: String, + result: anyhow::Result, +) -> ConnectionTestResult { + match result { + Ok(message) => ConnectionTestResult { + ok: true, + message, + provider, + tested_at: None, + message_key: Some("settings.connection_success".into()), + }, + Err(error) => ConnectionTestResult { + ok: false, + message: sanitize_connection_error(error), + provider, + tested_at: None, + message_key: None, + }, + } +} + +fn load_provider_status(path: &Path) -> ProviderStatusStore { + match std::fs::read_to_string(path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { + tracing::warn!( + "failed to parse provider status from {}: {}", + path.display(), + e + ); + ProviderStatusStore::default() + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProviderStatusStore::default(), + Err(e) => { + tracing::warn!( + "failed to read provider status from {}: {}", + path.display(), + e + ); + ProviderStatusStore::default() + } + } +} + +fn save_provider_status(path: &Path, status: &ProviderStatusStore) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(status)?; + std::fs::write(path, content)?; + Ok(()) +} + +fn provider_status_for_kind_mut<'a>( + store: &'a mut ProviderStatusStore, + kind: &str, +) -> Option<&'a mut BTreeMap> { + match kind { + "asr" => Some(&mut store.asr), + "llm" => Some(&mut store.llm), + _ => None, + } +} + +fn provider_status_for_kind<'a>( + store: &'a ProviderStatusStore, + kind: &str, +) -> Option<&'a BTreeMap> { + match kind { + "asr" => Some(&store.asr), + "llm" => Some(&store.llm), + _ => None, + } +} + +fn test_result_to_status(result: &ConnectionTestResult) -> ProviderConnectionStatus { + ProviderConnectionStatus { + tested_at: result.tested_at.clone().unwrap_or_default(), + ok: result.ok, + provider: result.provider.clone(), + message: if result.ok { + None + } else { + Some(result.message.clone()) + }, + message_key: result.message_key.clone(), + } +} + +fn asr_connection_test_fields_changed(old: &AsrConnection, new: &AsrConnection) -> bool { + old.provider != new.provider + || old.endpoint != new.endpoint + || old.api_key != new.api_key + || old.model != new.model + || old.language != new.language +} + +fn llm_connection_test_fields_changed(old: &LlmConnection, new: &LlmConnection) -> bool { + old.provider != new.provider + || old.endpoint != new.endpoint + || old.api_key != new.api_key + || old.model != new.model +} + +fn reset_changed_provider_status( + old_config: &AppConfig, + new_config: &AppConfig, + status: &mut ProviderStatusStore, +) -> bool { + let mut changed = false; + + for old in &old_config.asr.connections { + match new_config + .asr + .connections + .iter() + .find(|new| new.id == old.id) + { + Some(new) if asr_connection_test_fields_changed(old, new) => { + changed |= status.asr.remove(&old.id).is_some(); + } + None => { + changed |= status.asr.remove(&old.id).is_some(); + } + _ => {} + } + } + + for old in &old_config.llm.connections { + match new_config + .llm + .connections + .iter() + .find(|new| new.id == old.id) + { + Some(new) if llm_connection_test_fields_changed(old, new) => { + changed |= status.llm.remove(&old.id).is_some(); + } + None => { + changed |= status.llm.remove(&old.id).is_some(); + } + _ => {} + } + } + + let asr_ids: std::collections::BTreeSet<_> = new_config + .asr + .connections + .iter() + .map(|connection| connection.id.as_str()) + .collect(); + let before_asr = status.asr.len(); + status.asr.retain(|id, _| asr_ids.contains(id.as_str())); + changed |= status.asr.len() != before_asr; + + let llm_ids: std::collections::BTreeSet<_> = new_config + .llm + .connections + .iter() + .map(|connection| connection.id.as_str()) + .collect(); + let before_llm = status.llm.len(); + status.llm.retain(|id, _| llm_ids.contains(id.as_str())); + changed |= status.llm.len() != before_llm; + + changed +} + fn query_history(conn: &Connection, limit: usize) -> rusqlite::Result> { let sql = if limit > 0 { "SELECT id, text, created_at, pinned FROM history ORDER BY pinned DESC, id DESC LIMIT ?1" @@ -211,17 +462,157 @@ fn get_config(state: tauri::State) -> AppConfig { state.config.lock().unwrap().clone() } +#[tauri::command] +fn get_provider_connection_status( + state: tauri::State, + kind: String, + connection_id: String, +) -> Option { + let store = state.provider_status.lock().unwrap(); + provider_status_for_kind(&store, kind.trim()) + .and_then(|statuses| statuses.get(connection_id.trim()).cloned()) +} + +#[tauri::command] +async fn test_asr_connection( + app: tauri::AppHandle, + request: ConnectionTestRequest, +) -> ConnectionTestResult { + let connection_id = request + .connection_id + .clone() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| "default".into()); + let provider = request.provider.trim().to_string(); + let result = match provider.as_str() { + "mock" => Ok("Mock ASR is available.".to_string()), + "openai-compatible" | "" => test_openai_compatible_asr(request).await, + other => Err(anyhow::anyhow!("unsupported ASR provider: {}", other)), + }; + let mut result = connection_test_result(provider, result); + if result.ok && result.provider == "mock" { + result.message_key = Some("settings.mock_asr_ok".into()); + } + result.tested_at = Some(chrono::Utc::now().to_rfc3339()); + let state = app.state::(); + save_connection_test_status(&state, "asr", &connection_id, &result); + result +} + +#[tauri::command] +async fn test_llm_connection( + app: tauri::AppHandle, + request: ConnectionTestRequest, +) -> ConnectionTestResult { + let connection_id = request + .connection_id + .clone() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| "default".into()); + let provider = request.provider.trim().to_string(); + let result = match provider.as_str() { + "mock" | "" => Ok("Mock LLM is available.".to_string()), + "openai-compatible" => test_openai_compatible_llm(request).await, + other => Err(anyhow::anyhow!("unsupported LLM provider: {}", other)), + }; + let mut result = connection_test_result(provider, result); + if result.ok && result.provider == "mock" { + result.message_key = Some("settings.mock_llm_ok".into()); + } + result.tested_at = Some(chrono::Utc::now().to_rfc3339()); + let state = app.state::(); + save_connection_test_status(&state, "llm", &connection_id, &result); + result +} + +fn save_connection_test_status( + state: &AppState, + kind: &str, + connection_id: &str, + result: &ConnectionTestResult, +) { + let mut store = state.provider_status.lock().unwrap(); + if let Some(statuses) = provider_status_for_kind_mut(&mut store, kind) { + statuses.insert(connection_id.to_string(), test_result_to_status(result)); + if let Err(e) = save_provider_status(&state.provider_status_path, &store) { + tracing::warn!( + "failed to save provider status to {}: {}", + state.provider_status_path.display(), + e + ); + } + } +} + +async fn test_openai_compatible_asr(request: ConnectionTestRequest) -> anyhow::Result { + let endpoint = + trimmed_option(request.endpoint).unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + let api_key = api_key_or_env(request.api_key); + let model = trimmed_option(request.model).unwrap_or_else(|| "whisper-1".to_string()); + let mut provider = typex_core::typex_asr::openai_compat::OpenAiCompatibleAsrProvider::new( + endpoint, api_key, model, + ); + + if let Some(language) = trimmed_option(request.language) { + provider = provider.with_language(language); + } + + let pcm = vec![0_u8; 16_000 * 2]; + let wav = typex_core::typex_asr::pcm_to_wav(&pcm)?; + provider + .transcribe_file(wav, "typex-connection-test.wav") + .await?; + + Ok("ASR connection OK.".to_string()) +} + +async fn test_openai_compatible_llm(request: ConnectionTestRequest) -> anyhow::Result { + let endpoint = + trimmed_option(request.endpoint).unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + let api_key = api_key_or_env(request.api_key); + let model = trimmed_option(request.model).unwrap_or_else(|| "gpt-4o-mini".to_string()); + let provider = typex_core::typex_llm::openai_compat::OpenAiCompatibleLlmProvider::new( + endpoint, api_key, model, + ); + + let input = stream::once(async { Ok("ping".to_string()) }).boxed(); + let mut output = provider.optimize(input); + while let Some(item) = output.next().await { + let result = item?; + if result.is_final || !result.text.trim().is_empty() { + return Ok("LLM connection OK.".to_string()); + } + } + + anyhow::bail!("provider returned no response") +} + #[tauri::command] fn save_config(state: tauri::State, mut config: AppConfig) -> Result<(), String> { config.normalize_connections_mut(); + let old_config = state.config.lock().unwrap().clone(); // 1. Persist config to disk FIRST — always save user settings config.save(&state.config_path).map_err(|e| e.to_string())?; - // 2. Update in-memory config immediately + // 2. Reset stale provider test status when connection settings changed + let mut provider_status = state.provider_status.lock().unwrap(); + if reset_changed_provider_status(&old_config, &config, &mut provider_status) + && let Err(e) = save_provider_status(&state.provider_status_path, &provider_status) + { + tracing::warn!( + "failed to save provider status to {}: {}", + state.provider_status_path.display(), + e + ); + } + + // 3. Update in-memory config immediately *state.config.lock().unwrap() = config.clone(); - // 3. Rebuild pipeline (non-fatal: keep old pipeline on failure) + // 4. Rebuild pipeline (non-fatal: keep old pipeline on failure) match build_pipeline(&config) { Ok(pipeline) => { *state.pipeline.lock().unwrap() = pipeline; @@ -231,7 +622,7 @@ fn save_config(state: tauri::State, mut config: AppConfig) -> Result<( } } - // 4. Update audio capture if not currently recording + // 5. Update audio capture if not currently recording let capture = typex_audio::MicrophoneCapture::new(config.audio.device.clone()); if state.recording_stop.lock().unwrap().is_none() { state.capture.lock().unwrap().replace(capture); @@ -724,6 +1115,9 @@ pub fn run() { minimize, close_window, get_config, + get_provider_connection_status, + test_asr_connection, + test_llm_connection, save_config, get_history, search_history, @@ -739,6 +1133,8 @@ pub fn run() { .setup(move |app| { // Load or create config let cfg_path = config_path(app.handle()); + let status_path = provider_status_path(app.handle()); + let provider_status = load_provider_status(&status_path); let config = if cfg_path.exists() { AppConfig::load(&cfg_path).unwrap_or_else(|e| { tracing::warn!("failed to load config from {}: {}", cfg_path.display(), e); @@ -780,6 +1176,8 @@ pub fn run() { app.manage(AppState { config: Mutex::new(config), config_path: cfg_path.clone(), + provider_status_path: status_path, + provider_status: Mutex::new(provider_status), db: Mutex::new(conn), pipeline: Mutex::new(pipeline), capture: Mutex::new(Some(capture)),