Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -81,6 +84,123 @@ pub fn read_credential(account: String) -> Result<Option<String>, 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<String>,
}

#[tauri::command]
pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheckResult, String> {
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<ProviderModelsResult, String> {
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<ProviderConfig, String> {
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<Vec<String>, 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<Vec<String>, 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::<Vec<_>>();
models.sort();
models.dedup();
Ok(models)
}

fn parse_account(s: &str) -> Result<CredentialAccount, String> {
match s {
"volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey),
Expand Down Expand Up @@ -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()]);
}
}
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!())
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. {{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',
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '鉴权成功,可用模型 {{count}} 个。',
providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。',
apiKeyMissing: 'API Key 为空。',
endpointMissing: 'Endpoint 为空。',
requestTimeout: '请求超时,请稍后重试。',
},
shortcuts: {
title: '快捷键速查',
Expand Down
17 changes: 17 additions & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -131,6 +140,14 @@ export function readCredential(account: string): Promise<string | null> {
return invokeOrMock<string | null>('read_credential', { account }, () => null);
}

export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise<ProviderCheckResult> {
return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, modelCount: 2 }));
}

export function listProviderModels(kind: 'llm' | 'asr'): Promise<ProviderModelsResult> {
return invokeOrMock('list_provider_models', { kind }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-chat'] : ['whisper-1'] }));
}

// ── History ────────────────────────────────────────────────────────────
export function listHistory(): Promise<DictationSession[]> {
return invokeOrMock('list_history', undefined, () => mockHistory);
Expand Down
Loading
Loading