diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index ef74c7a7..14599065 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,123 @@ pub fn read_credential(account: String) -> Result, String> { CredentialsVault::get(acc).map_err(|e| e.to_string()) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCheckResult { + ok: bool, + model_count: usize, +} + +#[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, + model_count: 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 +445,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..65454910 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -246,6 +246,21 @@ 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', + 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.', + 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 cf98f614..6d9f99bf 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -244,6 +244,21 @@ export const zhCN = { appIdLabel: 'App ID(应用 ID)', accessKeyLabel: 'Access Key', resourceIdLabel: '资源 ID', + toolsLabel: '连接检查', + toolsDesc: '先保存上方配置,再验证鉴权或拉取模型;失败时仍可手动填写模型 ID。', + validate: '验证', + validating: '验证中…', + fetchModels: '拉取模型', + loadingModels: '拉取模型中…', + modelsEmpty: '鉴权成功,但没有返回可用模型。', + modelsLoaded: '已拉取 {{count}} 个模型。', + selectModel: '选择一个模型写入上方字段', + modelSaved: '已保存模型 {{model}}。', + validateSuccess: '鉴权成功,可用模型 {{count}} 个。', + 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..2602e6af 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; + modelCount: number; +} + +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, modelCount: 2 })); +} + +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..b0f7bc04 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,106 @@ 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 () => { + setModels([]); + setSelectedModel(''); + setResult('loading', t('settings.providers.validating')); + try { + const result = await validateProviderCredentials(kind); + 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)); + } + }; + + 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) => { + 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 ( + +
+
+ + + {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 +673,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)',