feat(asr): expose Foundry local ASR status
This commit is contained in:
1 parent
28ffe03cbf
commit
702e09405e
2 files changed
+160
-11
No files matched your search
@@ -7,6 +7,11 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::asr::local::foundry::{
|
||||
model_alias_is_known, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS,
|
||||
PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID,
|
||||
};
|
||||
use crate::asr::local::FoundryLocalRuntime;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
|
||||
@@ -124,9 +129,7 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
|
||||
if provider == "volcengine" {
|
||||
return volcengine_configured(snap);
|
||||
}
|
||||
if provider == crate::asr::local::PROVIDER_ID
|
||||
|| crate::asr::local::foundry::is_foundry_local_whisper(provider)
|
||||
{
|
||||
if provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID {
|
||||
// 本地 ASR 不依赖云端凭据。
|
||||
return true;
|
||||
}
|
||||
@@ -155,7 +158,10 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> {
|
||||
pub fn set_active_asr_provider(
|
||||
coord: CoordinatorState<'_>,
|
||||
provider: String,
|
||||
) -> Result<(), String> {
|
||||
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
|
||||
if provider == crate::asr::local::PROVIDER_ID {
|
||||
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
|
||||
@@ -282,6 +288,11 @@ async fn validate_llm_provider() -> Result<(), String> {
|
||||
}
|
||||
|
||||
async fn validate_asr_provider() -> Result<(), String> {
|
||||
let active_asr = CredentialsVault::get_active_asr();
|
||||
if active_asr_is_keyless_for_validation(&active_asr) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = read_openai_provider_config("asr")?;
|
||||
let model = CredentialsVault::get(CredentialAccount::AsrModel)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -290,6 +301,10 @@ async fn validate_asr_provider() -> Result<(), String> {
|
||||
validate_asr_transcription(&config, model.trim()).await
|
||||
}
|
||||
|
||||
fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
|
||||
provider == crate::asr::local::PROVIDER_ID || provider == FOUNDRY_LOCAL_PROVIDER_ID
|
||||
}
|
||||
|
||||
async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> {
|
||||
const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024;
|
||||
let url = asr_transcriptions_url(&config.base_url)?;
|
||||
@@ -769,7 +784,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> {
|
||||
pub fn local_asr_set_active_model(
|
||||
coord: CoordinatorState<'_>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
if ModelId::from_str(&model_id).is_none() {
|
||||
return Err(format!("unknown model id: {model_id}"));
|
||||
}
|
||||
@@ -827,10 +845,7 @@ pub fn local_asr_cancel_download(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_delete_model(
|
||||
coord: CoordinatorState<'_>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> {
|
||||
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
||||
// 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件,
|
||||
// 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。
|
||||
@@ -888,6 +903,76 @@ pub fn local_asr_set_keep_loaded_secs(
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ───────────────────── Windows local ASR (Foundry Local Whisper) ─────────────────────
|
||||
|
||||
fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String {
|
||||
if model_alias_is_known(&prefs.foundry_local_asr_model) {
|
||||
prefs.foundry_local_asr_model.clone()
|
||||
} else {
|
||||
DEFAULT_MODEL_ALIAS.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> {
|
||||
if model_alias_is_known(model_alias) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"unknown Foundry Whisper model alias: {model_alias}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_foundry_language_hint(language_hint: &str) -> Result<String, String> {
|
||||
let normalized = language_hint.trim().to_string();
|
||||
if normalized.is_empty()
|
||||
|| (normalized.len() == 2 && normalized.bytes().all(|b| b.is_ascii_lowercase()))
|
||||
{
|
||||
Ok(normalized)
|
||||
} else {
|
||||
Err("language hint must be empty or ISO 639-1 lowercase code".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn foundry_local_asr_status(
|
||||
coord: CoordinatorState<'_>,
|
||||
runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
) -> FoundryRuntimeStatus {
|
||||
let prefs = coord.prefs().get();
|
||||
let active_model = active_foundry_model_from_prefs(&prefs);
|
||||
runtime.status_snapshot(&active_model)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn foundry_local_asr_set_model(
|
||||
coord: CoordinatorState<'_>,
|
||||
model_alias: String,
|
||||
) -> Result<(), String> {
|
||||
validate_foundry_model_alias(&model_alias)?;
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.foundry_local_asr_model = model_alias;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn foundry_local_asr_set_language_hint(
|
||||
coord: CoordinatorState<'_>,
|
||||
language_hint: String,
|
||||
) -> Result<(), String> {
|
||||
let normalized = normalize_foundry_language_hint(&language_hint)?;
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.foundry_local_asr_language_hint = normalized;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn foundry_local_asr_release(
|
||||
runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
) -> Result<(), String> {
|
||||
runtime.release_now().await.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -896,9 +981,11 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
active_asr_is_keyless_for_validation, active_foundry_model_from_prefs,
|
||||
asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models,
|
||||
llm_configured_for_snapshot, models_url, parse_model_ids, persist_settings,
|
||||
ProviderConfig, SettingsWriter,
|
||||
llm_configured_for_snapshot, models_url,
|
||||
normalize_foundry_language_hint, parse_model_ids, persist_settings,
|
||||
validate_foundry_model_alias, ProviderConfig, SettingsWriter,
|
||||
};
|
||||
use crate::persistence::CredentialsSnapshot;
|
||||
use crate::types::{
|
||||
@@ -956,6 +1043,62 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_status_treats_foundry_local_asr_as_configured() {
|
||||
assert!(asr_configured_for_provider(
|
||||
crate::asr::local::foundry::PROVIDER_ID,
|
||||
&CredentialsSnapshot::default()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_asr_providers_skip_external_validation() {
|
||||
assert!(active_asr_is_keyless_for_validation(
|
||||
crate::asr::local::PROVIDER_ID
|
||||
));
|
||||
assert!(active_asr_is_keyless_for_validation(
|
||||
crate::asr::local::foundry::PROVIDER_ID
|
||||
));
|
||||
assert!(!active_asr_is_keyless_for_validation("volcengine"));
|
||||
assert!(!active_asr_is_keyless_for_validation("whisper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() {
|
||||
assert_eq!(normalize_foundry_language_hint("").unwrap(), "");
|
||||
assert_eq!(normalize_foundry_language_hint(" ").unwrap(), "");
|
||||
assert_eq!(normalize_foundry_language_hint("zh").unwrap(), "zh");
|
||||
assert_eq!(normalize_foundry_language_hint(" en ").unwrap(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundry_language_hint_rejects_non_lowercase_iso_639_1() {
|
||||
assert!(normalize_foundry_language_hint("ZH").is_err());
|
||||
assert!(normalize_foundry_language_hint("zho").is_err());
|
||||
assert!(normalize_foundry_language_hint("z1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundry_model_alias_validation_rejects_unknown_alias() {
|
||||
assert!(
|
||||
validate_foundry_model_alias(crate::asr::local::foundry::DEFAULT_MODEL_ALIAS).is_ok()
|
||||
);
|
||||
assert!(validate_foundry_model_alias("whisper-large").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundry_active_model_pref_falls_back_to_default_for_unknown_alias() {
|
||||
let prefs = UserPreferences {
|
||||
foundry_local_asr_model: "whisper-large".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
active_foundry_model_from_prefs(&prefs),
|
||||
crate::asr::local::foundry::DEFAULT_MODEL_ALIAS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_status_accepts_keyless_llm_with_endpoint_and_model() {
|
||||
let keyless_ready = CredentialsSnapshot {
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn run() {
|
||||
|
||||
let coordinator = Arc::new(coordinator::Coordinator::new());
|
||||
let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new());
|
||||
let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
// 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。
|
||||
@@ -71,6 +72,7 @@ pub fn run() {
|
||||
))
|
||||
.manage(coordinator.clone())
|
||||
.manage(local_asr_download_manager.clone())
|
||||
.manage(foundry_local_runtime.clone())
|
||||
.setup(move |app| {
|
||||
// Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。
|
||||
// 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。
|
||||
@@ -244,6 +246,10 @@ pub fn run() {
|
||||
commands::local_asr_release_engine,
|
||||
commands::local_asr_preload,
|
||||
commands::local_asr_set_keep_loaded_secs,
|
||||
commands::foundry_local_asr_status,
|
||||
commands::foundry_local_asr_set_model,
|
||||
commands::foundry_local_asr_set_language_hint,
|
||||
commands::foundry_local_asr_release,
|
||||
restart_app,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
|
||||
Reference in new issue
Block a user