feat(asr): expose Foundry local ASR status

This commit is contained in:
millionart committed 2026-05-06 16:43:47 +08:00
1 parent 28ffe03cbf
commit 702e09405e
2 files changed
+160 -11

No files matched your search

+154 -11
View File
@@ -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 {
+6
View File
@@ -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!())