Skip to content

Commit 6ef2374

Browse files
authored
Merge pull request #122 from H-Chris233/main
feat(settings): 增加供应商鉴权与模型拉取
2 parents 5650a9a + 1967ba7 commit 6ef2374

6 files changed

Lines changed: 311 additions & 2 deletions

File tree

openless-all/app/src-tauri/src/commands.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
//! Tauri command surface — every IPC entry the React UI invokes lives here.
22
33
use std::sync::Arc;
4+
use std::time::Duration;
45

6+
use serde::Serialize;
7+
use serde_json::Value;
58
use tauri::{AppHandle, State};
69

710
use crate::coordinator::Coordinator;
@@ -81,6 +84,123 @@ pub fn read_credential(account: String) -> Result<Option<String>, String> {
8184
CredentialsVault::get(acc).map_err(|e| e.to_string())
8285
}
8386

87+
#[derive(Serialize)]
88+
#[serde(rename_all = "camelCase")]
89+
pub struct ProviderCheckResult {
90+
ok: bool,
91+
model_count: usize,
92+
}
93+
94+
#[derive(Serialize)]
95+
pub struct ProviderModelsResult {
96+
models: Vec<String>,
97+
}
98+
99+
#[tauri::command]
100+
pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheckResult, String> {
101+
let config = read_openai_provider_config(&kind)?;
102+
fetch_provider_models(&config)
103+
.await
104+
.map(|models| ProviderCheckResult {
105+
ok: true,
106+
model_count: models.len(),
107+
})
108+
}
109+
110+
#[tauri::command]
111+
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
112+
let config = read_openai_provider_config(&kind)?;
113+
fetch_provider_models(&config)
114+
.await
115+
.map(|models| ProviderModelsResult { models })
116+
}
117+
118+
struct ProviderConfig {
119+
base_url: String,
120+
api_key: String,
121+
}
122+
123+
fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
124+
let (api_key_account, endpoint_account) = match kind {
125+
"llm" => (CredentialAccount::ArkApiKey, CredentialAccount::ArkEndpoint),
126+
"asr" => (CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint),
127+
_ => return Err(format!("unknown provider kind: {kind}")),
128+
};
129+
let api_key = CredentialsVault::get(api_key_account)
130+
.map_err(|e| e.to_string())?
131+
.unwrap_or_default();
132+
let base_url = CredentialsVault::get(endpoint_account)
133+
.map_err(|e| e.to_string())?
134+
.unwrap_or_default();
135+
if api_key.trim().is_empty() {
136+
return Err("API Key 为空".to_string());
137+
}
138+
if base_url.trim().is_empty() {
139+
return Err("Endpoint 为空".to_string());
140+
}
141+
Ok(ProviderConfig { base_url, api_key })
142+
}
143+
144+
async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, String> {
145+
let url = models_url(&config.base_url);
146+
log::info!("[provider-check] GET {url}");
147+
let client = reqwest::Client::builder()
148+
.timeout(Duration::from_secs(15))
149+
.build()
150+
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
151+
let response = client
152+
.get(&url)
153+
.header("Authorization", format!("Bearer {}", config.api_key))
154+
.send()
155+
.await
156+
.map_err(|e| {
157+
if e.is_timeout() {
158+
"请求超时".to_string()
159+
} else {
160+
format!("网络错误: {e}")
161+
}
162+
})?;
163+
let status = response.status();
164+
let body = response
165+
.text()
166+
.await
167+
.map_err(|e| format!("读取响应失败: {e}"))?;
168+
if !status.is_success() {
169+
return Err(format!("providerHttpStatus:{}", status.as_u16()));
170+
}
171+
parse_model_ids(&body)
172+
}
173+
174+
fn models_url(base_url: &str) -> String {
175+
let trimmed = base_url.trim().trim_end_matches('/');
176+
if trimmed.ends_with("/models") {
177+
return trimmed.to_string();
178+
}
179+
if let Some(prefix) = trimmed.strip_suffix("/chat/completions") {
180+
return format!("{prefix}/models");
181+
}
182+
format!("{trimmed}/models")
183+
}
184+
185+
fn parse_model_ids(body: &str) -> Result<Vec<String>, String> {
186+
let json: Value =
187+
serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?;
188+
let data = json
189+
.get("data")
190+
.and_then(|v| v.as_array())
191+
.ok_or_else(|| "模型列表缺少 data 数组".to_string())?;
192+
let mut models = data
193+
.iter()
194+
.filter_map(|item| item.get("id").and_then(|id| id.as_str()))
195+
.map(str::trim)
196+
.filter(|id| !id.is_empty())
197+
.map(ToOwned::to_owned)
198+
.collect::<Vec<_>>();
199+
models.sort();
200+
models.dedup();
201+
Ok(models)
202+
}
203+
84204
fn parse_account(s: &str) -> Result<CredentialAccount, String> {
85205
match s {
86206
"volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey),
@@ -325,3 +445,28 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> {
325445

326446
#[allow(dead_code)]
327447
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
448+
449+
#[cfg(test)]
450+
mod tests {
451+
use super::{models_url, parse_model_ids};
452+
453+
#[test]
454+
fn models_url_accepts_base_or_chat_endpoint() {
455+
assert_eq!(
456+
models_url("https://api.openai.com/v1"),
457+
"https://api.openai.com/v1/models"
458+
);
459+
assert_eq!(
460+
models_url("https://api.openai.com/v1/chat/completions"),
461+
"https://api.openai.com/v1/models"
462+
);
463+
}
464+
465+
#[test]
466+
fn parse_model_ids_sorts_and_deduplicates() {
467+
let models =
468+
parse_model_ids(r#"{ "data": [{ "id": "b" }, { "id": "a" }, { "id": "b" }] }"#)
469+
.unwrap();
470+
assert_eq!(models, vec!["a".to_string(), "b".to_string()]);
471+
}
472+
}

openless-all/app/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ pub fn run() {
175175
commands::read_credential,
176176
commands::set_active_asr_provider,
177177
commands::set_active_llm_provider,
178+
commands::validate_provider_credentials,
179+
commands::list_provider_models,
178180
restart_app,
179181
])
180182
.build(tauri::generate_context!())

openless-all/app/src/i18n/en.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,21 @@ export const en: typeof zhCN = {
246246
appIdLabel: 'App ID',
247247
accessKeyLabel: 'Access Key',
248248
resourceIdLabel: 'Resource ID',
249+
toolsLabel: 'Connection check',
250+
toolsDesc: 'Save the fields above, then validate credentials or fetch models. Manual model input remains available if fetching fails.',
251+
validate: 'Validate',
252+
validating: 'Validating…',
253+
fetchModels: 'Fetch models',
254+
loadingModels: 'Fetching models…',
255+
modelsEmpty: 'Credentials are valid, but no models were returned.',
256+
modelsLoaded: 'Fetched {{count}} models.',
257+
selectModel: 'Select a model to fill the field above',
258+
modelSaved: 'Saved model {{model}}.',
259+
validateSuccess: 'Credentials are valid. {{count}} models available.',
260+
providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.',
261+
apiKeyMissing: 'API Key is empty.',
262+
endpointMissing: 'Endpoint is empty.',
263+
requestTimeout: 'Request timed out. Try again later.',
249264
},
250265
shortcuts: {
251266
title: 'Shortcut reference',

openless-all/app/src/i18n/zh-CN.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,21 @@ export const zhCN = {
244244
appIdLabel: 'App ID(应用 ID)',
245245
accessKeyLabel: 'Access Key',
246246
resourceIdLabel: '资源 ID',
247+
toolsLabel: '连接检查',
248+
toolsDesc: '先保存上方配置,再验证鉴权或拉取模型;失败时仍可手动填写模型 ID。',
249+
validate: '验证',
250+
validating: '验证中…',
251+
fetchModels: '拉取模型',
252+
loadingModels: '拉取模型中…',
253+
modelsEmpty: '鉴权成功,但没有返回可用模型。',
254+
modelsLoaded: '已拉取 {{count}} 个模型。',
255+
selectModel: '选择一个模型写入上方字段',
256+
modelSaved: '已保存模型 {{model}}。',
257+
validateSuccess: '鉴权成功,可用模型 {{count}} 个。',
258+
providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。',
259+
apiKeyMissing: 'API Key 为空。',
260+
endpointMissing: 'Endpoint 为空。',
261+
requestTimeout: '请求超时,请稍后重试。',
247262
},
248263
shortcuts: {
249264
title: '快捷键速查',

openless-all/app/src/lib/ipc.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ const mockCredentialsStatus: CredentialsStatus = {
6363
arkConfigured: true,
6464
};
6565

66+
export interface ProviderCheckResult {
67+
ok: boolean;
68+
modelCount: number;
69+
}
70+
71+
export interface ProviderModelsResult {
72+
models: string[];
73+
}
74+
6675
const mockHotkeyStatus: HotkeyStatus = {
6776
adapter: 'windowsLowLevel',
6877
state: 'installed',
@@ -131,6 +140,14 @@ export function readCredential(account: string): Promise<string | null> {
131140
return invokeOrMock<string | null>('read_credential', { account }, () => null);
132141
}
133142

143+
export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise<ProviderCheckResult> {
144+
return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, modelCount: 2 }));
145+
}
146+
147+
export function listProviderModels(kind: 'llm' | 'asr'): Promise<ProviderModelsResult> {
148+
return invokeOrMock('list_provider_models', { kind }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-chat'] : ['whisper-1'] }));
149+
}
150+
134151
// ── History ────────────────────────────────────────────────────────────
135152
export function listHistory(): Promise<DictationSession[]> {
136153
return invokeOrMock('list_history', undefined, () => mockHistory);

0 commit comments

Comments
 (0)