From 2bb2930f6c64c68b93ad34145014f872ffc75599 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 1 May 2026 11:14:16 +0800 Subject: [PATCH 1/3] Let users verify provider credentials before dictation Provider setup needed an explicit way to validate OpenAI-compatible credentials and discover account models without forcing manual model IDs. Add minimal IPC commands for /models checks and a small Settings UI control that preserves manual entry when discovery fails. Constraint: Do not leak API keys in logs or user-visible provider error bodies. Rejected: Full provider-specific SDK integrations | OpenAI-compatible /models covers the configured providers with less scope. Confidence: medium Scope-risk: moderate Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml commands::tests -- --nocapture Tested: npm run build Tested: git diff --check --- openless-all/app/src-tauri/src/commands.rs | 148 +++++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src/i18n/en.ts | 14 ++ openless-all/app/src/i18n/zh-CN.ts | 14 ++ openless-all/app/src/lib/ipc.ts | 17 +++ openless-all/app/src/pages/Settings.tsx | 105 ++++++++++++++- 6 files changed, 298 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index ef74c7a7..3dc46b53 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1,7 +1,10 @@ //! Tauri command surface — every IPC entry the React UI invokes lives here. use std::sync::Arc; +use std::time::Duration; +use serde::Serialize; +use serde_json::Value; use tauri::{AppHandle, State}; use crate::coordinator::Coordinator; @@ -81,6 +84,126 @@ pub fn read_credential(account: String) -> Result, String> { CredentialsVault::get(acc).map_err(|e| e.to_string()) } +#[derive(Serialize)] +pub struct ProviderCheckResult { + ok: bool, + message: String, +} + +#[derive(Serialize)] +pub struct ProviderModelsResult { + models: Vec, +} + +#[tauri::command] +pub async fn validate_provider_credentials(kind: String) -> Result { + let config = read_openai_provider_config(&kind)?; + fetch_provider_models(&config) + .await + .map(|models| ProviderCheckResult { + ok: true, + message: if models.is_empty() { + "鉴权成功,但账号未返回可用模型".to_string() + } else { + format!("鉴权成功,可用模型 {} 个", models.len()) + }, + }) +} + +#[tauri::command] +pub async fn list_provider_models(kind: String) -> Result { + let config = read_openai_provider_config(&kind)?; + fetch_provider_models(&config) + .await + .map(|models| ProviderModelsResult { models }) +} + +struct ProviderConfig { + base_url: String, + api_key: String, +} + +fn read_openai_provider_config(kind: &str) -> Result { + let (api_key_account, endpoint_account) = match kind { + "llm" => (CredentialAccount::ArkApiKey, CredentialAccount::ArkEndpoint), + "asr" => (CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint), + _ => return Err(format!("unknown provider kind: {kind}")), + }; + let api_key = CredentialsVault::get(api_key_account) + .map_err(|e| e.to_string())? + .unwrap_or_default(); + let base_url = CredentialsVault::get(endpoint_account) + .map_err(|e| e.to_string())? + .unwrap_or_default(); + if api_key.trim().is_empty() { + return Err("API Key 为空".to_string()); + } + if base_url.trim().is_empty() { + return Err("Endpoint 为空".to_string()); + } + Ok(ProviderConfig { base_url, api_key }) +} + +async fn fetch_provider_models(config: &ProviderConfig) -> Result, String> { + let url = models_url(&config.base_url); + log::info!("[provider-check] GET {url}"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|e| format!("HTTP client 初始化失败: {e}"))?; + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", config.api_key)) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + "请求超时".to_string() + } else { + format!("网络错误: {e}") + } + })?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + if !status.is_success() { + return Err(format!("providerHttpStatus:{}", status.as_u16())); + } + parse_model_ids(&body) +} + +fn models_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.ends_with("/models") { + return trimmed.to_string(); + } + if let Some(prefix) = trimmed.strip_suffix("/chat/completions") { + return format!("{prefix}/models"); + } + format!("{trimmed}/models") +} + +fn parse_model_ids(body: &str) -> Result, String> { + let json: Value = + serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?; + let data = json + .get("data") + .and_then(|v| v.as_array()) + .ok_or_else(|| "模型列表缺少 data 数组".to_string())?; + let mut models = data + .iter() + .filter_map(|item| item.get("id").and_then(|id| id.as_str())) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + models.sort(); + models.dedup(); + Ok(models) +} + fn parse_account(s: &str) -> Result { match s { "volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey), @@ -325,3 +448,28 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> { #[allow(dead_code)] fn _ensure_snapshot_used(_: CredentialsSnapshot) {} + +#[cfg(test)] +mod tests { + use super::{models_url, parse_model_ids}; + + #[test] + fn models_url_accepts_base_or_chat_endpoint() { + assert_eq!( + models_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/models" + ); + assert_eq!( + models_url("https://api.openai.com/v1/chat/completions"), + "https://api.openai.com/v1/models" + ); + } + + #[test] + fn parse_model_ids_sorts_and_deduplicates() { + let models = + parse_model_ids(r#"{ "data": [{ "id": "b" }, { "id": "a" }, { "id": "b" }] }"#) + .unwrap(); + assert_eq!(models, vec!["a".to_string(), "b".to_string()]); + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4d7b9f10..05c8b46c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -175,6 +175,8 @@ pub fn run() { commands::read_credential, commands::set_active_asr_provider, commands::set_active_llm_provider, + commands::validate_provider_credentials, + commands::list_provider_models, restart_app, ]) .build(tauri::generate_context!()) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fa335452..0ebaad48 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -246,6 +246,20 @@ export const en: typeof zhCN = { appIdLabel: 'App ID', accessKeyLabel: 'Access Key', resourceIdLabel: 'Resource ID', + toolsLabel: 'Connection check', + toolsDesc: 'Save the fields above, then validate credentials or fetch models. Manual model input remains available if fetching fails.', + validate: 'Validate', + validating: 'Validating…', + fetchModels: 'Fetch models', + loadingModels: 'Fetching models…', + modelsEmpty: 'Credentials are valid, but no models were returned.', + modelsLoaded: 'Fetched {{count}} models.', + selectModel: 'Select a model to fill the field above', + validateSuccess: 'Credentials are valid.', + providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', + apiKeyMissing: 'API Key is empty.', + endpointMissing: 'Endpoint is empty.', + requestTimeout: 'Request timed out. Try again later.', }, shortcuts: { title: 'Shortcut reference', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 408ba0f3..00f713b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -244,6 +244,20 @@ export const zhCN = { appIdLabel: 'App ID(应用 ID)', accessKeyLabel: 'Access Key(访问密钥)', resourceIdLabel: '资源 ID', + toolsLabel: '连接检查', + toolsDesc: '先保存上方配置,再验证鉴权或拉取模型;失败时仍可手动填写模型 ID。', + validate: '验证', + validating: '验证中…', + fetchModels: '拉取模型', + loadingModels: '拉取模型中…', + modelsEmpty: '鉴权成功,但没有返回可用模型。', + modelsLoaded: '已拉取 {{count}} 个模型。', + selectModel: '选择一个模型写入上方字段', + validateSuccess: '鉴权成功。', + providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', + apiKeyMissing: 'API Key 为空。', + endpointMissing: 'Endpoint 为空。', + requestTimeout: '请求超时,请稍后重试。', }, shortcuts: { title: '快捷键速查', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a1cabd6b..b17658a3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -63,6 +63,15 @@ const mockCredentialsStatus: CredentialsStatus = { arkConfigured: true, }; +export interface ProviderCheckResult { + ok: boolean; + message: string; +} + +export interface ProviderModelsResult { + models: string[]; +} + const mockHotkeyStatus: HotkeyStatus = { adapter: 'windowsLowLevel', state: 'installed', @@ -131,6 +140,14 @@ export function readCredential(account: string): Promise { return invokeOrMock('read_credential', { account }, () => null); } +export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise { + return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, message: 'Mock credentials are valid.' })); +} + +export function listProviderModels(kind: 'llm' | 'asr'): Promise { + return invokeOrMock('list_provider_models', { kind }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-chat'] : ['whisper-1'] })); +} + // ── History ──────────────────────────────────────────────────────────── export function listHistory(): Promise { return invokeOrMock('list_history', undefined, () => mockHistory); diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 08f70b28..bc9eab3d 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -15,12 +15,14 @@ import { getHotkeyStatus, openExternal, openSystemSettings, + listProviderModels, readCredential, requestAccessibilityPermission, requestMicrophonePermission, setActiveAsrProvider, setActiveLlmProvider, setCredential, + validateProviderCredentials, } from '../lib/ipc'; import type { HotkeyCapability, @@ -286,6 +288,8 @@ function ProvidersSection() { const { prefs, updatePrefs } = useHotkeySettings(); const [llmProvider, setLlmProvider] = useState('ark'); const [asrProvider, setAsrProvider] = useState('volcengine'); + const [llmModelRevision, setLlmModelRevision] = useState(0); + const [asrModelRevision, setAsrModelRevision] = useState(0); useEffect(() => { if (!prefs) return; @@ -342,8 +346,9 @@ function ProvidersSection() { - + setLlmModelRevision(v => v + 1)} /> @@ -374,8 +379,9 @@ function ProvidersSection() { - + setAsrModelRevision(v => v + 1)} /> )} @@ -383,6 +389,92 @@ function ProvidersSection() { ); } +type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; + +function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) { + const { t } = useTranslation(); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(''); + + const setResult = (next: ProviderToolStatus, nextMessage: string) => { + setStatus(next); + setMessage(nextMessage); + }; + + const validate = async () => { + setResult('loading', t('settings.providers.validating')); + try { + const result = await validateProviderCredentials(kind); + setResult(result.ok ? 'success' : 'error', result.message || t('settings.providers.validateSuccess')); + } catch (error) { + setResult('error', providerErrorMessage(error, t)); + } + }; + + const loadModels = async () => { + setResult('loading', t('settings.providers.loadingModels')); + try { + const result = await listProviderModels(kind); + setModels(result.models); + if (result.models.length === 0) { + setResult('empty', t('settings.providers.modelsEmpty')); + } else { + setSelectedModel(''); + setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length })); + } + } catch (error) { + setModels([]); + setResult('error', providerErrorMessage(error, t)); + } + }; + + const applyModel = async (model: string) => { + setSelectedModel(model); + await setCredential(modelAccount, model); + onModelSelected(); + }; + + return ( + +
+
+ + + {models.length > 0 && ( + + )} +
+ {message && ( + + {message} + + )} +
+
+ ); +} + + +function providerErrorMessage(error: unknown, t: ReturnType['t']): string { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith('providerHttpStatus:')) { + return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' }); + } + if (message.includes('API Key')) return t('settings.providers.apiKeyMissing'); + if (message.includes('Endpoint')) return t('settings.providers.endpointMissing'); + if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout'); + return t('common.operationFailed'); +} + type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'; interface CredentialFieldProps { @@ -567,6 +659,15 @@ const inputStyle: CSSProperties = { width: '100%', maxWidth: 360, transition: 'background 0.12s ease-out, border-color 0.12s ease-out', }; +const miniBtnStyle: CSSProperties = { + height: 32, padding: '0 10px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, background: 'var(--ol-surface)', + color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0, + fontSize: 12, fontWeight: 500, + transition: 'background 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out', +}; + const iconBtnStyle: CSSProperties = { width: 32, height: 32, border: '0.5px solid var(--ol-line-strong)', From 2fc4299f069a9bd78742f763fee978aa66745fbc Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 1 May 2026 15:28:49 +0800 Subject: [PATCH 2/3] Keep provider validation messages locale neutral The credential validation IPC returned Chinese success text that Settings rendered directly. Return a structured model count instead and let the frontend localize success or empty-list states through existing i18n resources. Constraint: Review comment requires locale-neutral validation output. Rejected: Returning message keys from Rust | model count is simpler and keeps presentation entirely in the frontend. Confidence: high Scope-risk: narrow Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml Tested: npm run build Tested: git diff --check --- openless-all/app/src-tauri/src/commands.rs | 9 +++------ openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/lib/ipc.ts | 4 ++-- openless-all/app/src/pages/Settings.tsx | 7 ++++++- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 3dc46b53..14599065 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -85,9 +85,10 @@ pub fn read_credential(account: String) -> Result, String> { } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct ProviderCheckResult { ok: bool, - message: String, + model_count: usize, } #[derive(Serialize)] @@ -102,11 +103,7 @@ pub async fn validate_provider_credentials(kind: String) -> Result { } export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise { - return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, message: 'Mock credentials are valid.' })); + return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, modelCount: 2 })); } export function listProviderModels(kind: 'llm' | 'asr'): Promise { diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index bc9eab3d..c4590c72 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -407,7 +407,12 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | setResult('loading', t('settings.providers.validating')); try { const result = await validateProviderCredentials(kind); - setResult(result.ok ? 'success' : 'error', result.message || t('settings.providers.validateSuccess')); + setResult( + result.ok ? (result.modelCount === 0 ? 'empty' : 'success') : 'error', + result.modelCount === 0 + ? t('settings.providers.modelsEmpty') + : t('settings.providers.validateSuccess', { count: result.modelCount }), + ); } catch (error) { setResult('error', providerErrorMessage(error, t)); } From 1967ba75b24f91fa6bf7ad1b55299e92f97e5508 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 1 May 2026 17:15:34 +0800 Subject: [PATCH 3/3] Prevent stale provider model selections Validation could leave a previously fetched model list visible after credentials changed or validation failed, and model selection updated before persistence completed. Clear stale models before validation and only reflect selected models after the credential save succeeds. Constraint: Address review feedback without changing provider discovery scope. Confidence: high Scope-risk: narrow Tested: npm run build Tested: git diff --check --- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/pages/Settings.tsx | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 01c8e94b..65454910 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -255,6 +255,7 @@ export const en: typeof zhCN = { modelsEmpty: 'Credentials are valid, but no models were returned.', modelsLoaded: 'Fetched {{count}} models.', selectModel: 'Select a model to fill the field above', + modelSaved: 'Saved model {{model}}.', validateSuccess: 'Credentials are valid. {{count}} models available.', providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', apiKeyMissing: 'API Key is empty.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 8236a02a..6d9f99bf 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -253,6 +253,7 @@ export const zhCN = { modelsEmpty: '鉴权成功,但没有返回可用模型。', modelsLoaded: '已拉取 {{count}} 个模型。', selectModel: '选择一个模型写入上方字段', + modelSaved: '已保存模型 {{model}}。', validateSuccess: '鉴权成功,可用模型 {{count}} 个。', providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', apiKeyMissing: 'API Key 为空。', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index c4590c72..b0f7bc04 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -404,6 +404,8 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | }; const validate = async () => { + setModels([]); + setSelectedModel(''); setResult('loading', t('settings.providers.validating')); try { const result = await validateProviderCredentials(kind); @@ -436,9 +438,15 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | }; const applyModel = async (model: string) => { - setSelectedModel(model); - await setCredential(modelAccount, model); - onModelSelected(); + setResult('loading', t('common.saving')); + try { + await setCredential(modelAccount, model); + setSelectedModel(model); + onModelSelected(); + setResult('success', t('settings.providers.modelSaved', { model })); + } catch (error) { + setResult('error', providerErrorMessage(error, t)); + } }; return ( @@ -451,6 +459,7 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' |