Merge pull request #122 from H-Chris233/main
feat(settings): 增加供应商鉴权与模型拉取
This commit is contained in:
6 files changed
+311
-2
No files matched your search
@@ -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<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),
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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!())
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '快捷键速查',
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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<LlmPresetId>('ark');
|
||||
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefs) return;
|
||||
@@ -342,8 +346,9 @@ function ProvidersSection() {
|
||||
<CredentialField key={`${llmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${llmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
|
||||
<CredentialField key={`${llmProvider}:model`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
<CredentialField key={`${llmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
placeholder={preset.modelPlaceholder || 'model-name'} mono />
|
||||
<ProviderTools key={llmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -374,8 +379,9 @@ function ProvidersSection() {
|
||||
<CredentialField key={`${asrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
<CredentialField key={`${asrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
|
||||
placeholder="https://api.openai.com/v1" defaultValue="https://api.openai.com/v1" />
|
||||
<CredentialField key={`${asrProvider}:model`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
<CredentialField key={`${asrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
placeholder="whisper-1" />
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -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<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [status, setStatus] = useState<ProviderToolStatus>('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 (
|
||||
<SettingRow label={t('settings.providers.toolsLabel')} desc={t('settings.providers.toolsDesc')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
|
||||
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
|
||||
{models.length > 0 && (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={e => applyModel(e.target.value)}
|
||||
disabled={status === 'loading'}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
>
|
||||
<option value="" disabled>{t('settings.providers.selectModel')}</option>
|
||||
{models.map(model => <option key={model} value={model}>{model}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['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)',
|
||||
|
||||
Reference in new issue
Block a user