From aa5add11880e5583de6a6cdbae469dc803a5379a Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 8 May 2026 11:42:07 +0800 Subject: [PATCH] [codex] fix Foundry Windows App Runtime prompt (#340) --- openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 1 + .../app/src-tauri/src/asr/local/cache.rs | 5 +- .../app/src-tauri/src/asr/local/foundry.rs | 6 + .../src-tauri/src/asr/local/foundry_native.rs | 678 ++++++++++++++++++ .../src/asr/local/foundry_provider.rs | 6 + .../src/asr/local/foundry_runtime.rs | 175 ++++- .../app/src-tauri/src/asr/local/mod.rs | 1 + openless-all/app/src-tauri/src/audio_mute.rs | 4 +- openless-all/app/src-tauri/src/commands.rs | 27 +- openless-all/app/src-tauri/src/coordinator.rs | 2 + openless-all/app/src-tauri/src/lib.rs | 76 +- openless-all/app/src-tauri/src/persistence.rs | 37 + openless-all/app/src-tauri/src/polish.rs | 60 +- openless-all/app/src-tauri/src/types.rs | 15 + openless-all/app/src/i18n/en.ts | 11 +- openless-all/app/src/i18n/ja.ts | 11 +- openless-all/app/src/i18n/ko.ts | 11 +- openless-all/app/src/i18n/zh-CN.ts | 11 +- openless-all/app/src/i18n/zh-TW.ts | 11 +- openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/localAsr.ts | 13 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/LocalAsr.tsx | 64 +- 25 files changed, 1103 insertions(+), 127 deletions(-) create mode 100644 openless-all/app/src-tauri/src/asr/local/foundry_native.rs diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index af91b59b..bed3b70e 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3797,6 +3797,7 @@ dependencies = [ "window-vibrancy 0.7.1", "windows 0.58.0", "winreg 0.52.0", + "zip 2.4.2", ] [[package]] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index a2df054b..b7f4af82 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] } +zip = "2" thiserror = "1" anyhow = "1" log = "0.4" diff --git a/openless-all/app/src-tauri/src/asr/local/cache.rs b/openless-all/app/src-tauri/src/asr/local/cache.rs index f194a65c..e580801b 100644 --- a/openless-all/app/src-tauri/src/asr/local/cache.rs +++ b/openless-all/app/src-tauri/src/asr/local/cache.rs @@ -154,7 +154,10 @@ impl LocalAsrCache { fn pressure_relief_macos() { // SAFETY: 系统 API;NULL zone + goal=0 = 对所有 zone 尽量多地归还,无内存安全风险。 let freed = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) }; - log::info!("[local-asr cache] malloc_zone_pressure_relief freed ~{} bytes", freed); + log::info!( + "[local-asr cache] malloc_zone_pressure_relief freed ~{} bytes", + freed + ); } #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/asr/local/foundry.rs b/openless-all/app/src-tauri/src/asr/local/foundry.rs index 4415f102..5f7fe774 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry.rs @@ -182,6 +182,8 @@ impl FoundryPrepareProgressPayload { pub struct FoundryRuntimeStatus { pub provider_id: String, pub available: bool, + pub runtime_ready: bool, + pub runtime_source: String, pub active_model: String, pub loaded_model_id: Option, pub endpoint: Option, @@ -194,6 +196,8 @@ impl FoundryRuntimeStatus { Self { provider_id: PROVIDER_ID.into(), available: false, + runtime_ready: false, + runtime_source: "auto".into(), active_model, loaded_model_id: None, endpoint: None, @@ -223,6 +227,8 @@ mod tests { assert_eq!(status.provider_id, PROVIDER_ID); assert!(!status.available); + assert!(!status.runtime_ready); + assert_eq!(status.runtime_source, "auto"); assert_eq!(status.active_model, "whisper-base"); assert_eq!(status.loaded_model_id, None); assert_eq!(status.endpoint, None); diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_native.rs b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs new file mode 100644 index 00000000..285c4337 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs @@ -0,0 +1,678 @@ +#[cfg(target_os = "windows")] +mod imp { + use std::fs; + use std::io::{self, Read}; + use std::os::windows::process::CommandExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + use std::time::Duration; + + use anyhow::{Context, Result}; + use serde_json::Value; + use tokio::task; + + const NUGET_FEED_INDEX: &str = "https://api.nuget.org/v3/index.json"; + const ORT_NIGHTLY_FEED_INDEX: &str = + "https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json"; + const TARGET_RID_NATIVE_PREFIX: &str = "runtimes/win-x64/native/"; + const CORE_DLL: &str = "Microsoft.AI.Foundry.Local.Core.dll"; + const REQUIRED_DLLS: &[&str] = &[CORE_DLL, "onnxruntime.dll", "onnxruntime-genai.dll"]; + const WINDOWS_APP_RUNTIME_INSTALLER_URL: &str = + "https://aka.ms/windowsappsdk/1.8/1.8.260416003/windowsappruntimeinstall-x64.exe"; + const WINDOWS_APP_RUNTIME_INSTALLER_FILE: &str = "WindowsAppRuntimeInstall-x64.exe"; + const WINDOWS_APP_RUNTIME_MANUAL_STEPS: &str = + "Install Windows App SDK Runtime 1.8 x64 manually from https://aka.ms/windowsappsdk/1.8/1.8.260416003/windowsappruntimeinstall-x64.exe. On x64 Windows it must register x86+x64 Framework/DDLM packages and x64 Main/Singleton packages for the current user. If this PC is managed by an organization, enable MSIX/sideloading policy or ask IT to provision the runtime."; + const WINDOWS_APP_RUNTIME_PROGRESS_START: f64 = 0.0; + const WINDOWS_APP_RUNTIME_PROGRESS_END: f64 = 20.0; + const NATIVE_RUNTIME_PROGRESS_START: f64 = WINDOWS_APP_RUNTIME_PROGRESS_END; + const NATIVE_RUNTIME_PROGRESS_END: f64 = 98.0; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + #[derive(Debug)] + struct CommandCapture { + status_code: Option, + stdout: String, + stderr: String, + } + + impl CommandCapture { + fn success(&self) -> bool { + self.status_code == Some(0) + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum RuntimeSource { + Auto, + Nuget, + OrtNightly, + } + + impl RuntimeSource { + pub fn as_str(self) -> &'static str { + match self { + RuntimeSource::Auto => "auto", + RuntimeSource::Nuget => "nuget", + RuntimeSource::OrtNightly => "ort-nightly", + } + } + } + + struct NativePackage { + name: &'static str, + version: &'static str, + expected_file: &'static str, + } + + const PACKAGES: &[NativePackage] = &[ + NativePackage { + name: "Microsoft.AI.Foundry.Local.Core.WinML", + version: "1.0.0", + expected_file: CORE_DLL, + }, + NativePackage { + name: "Microsoft.ML.OnnxRuntime.Foundry", + version: "1.23.2.3", + expected_file: "onnxruntime.dll", + }, + NativePackage { + name: "Microsoft.ML.OnnxRuntimeGenAI.Foundry", + version: "0.13.2", + expected_file: "onnxruntime-genai.dll", + }, + ]; + + pub fn normalize_runtime_source(value: &str) -> RuntimeSource { + match value.trim() { + "nuget" => RuntimeSource::Nuget, + "ort-nightly" => RuntimeSource::OrtNightly, + _ => RuntimeSource::Auto, + } + } + + pub fn normalize_runtime_source_str(value: &str) -> String { + normalize_runtime_source(value).as_str().to_string() + } + + pub fn feed_indexes_for_runtime_source(source: RuntimeSource) -> Vec<&'static str> { + match source { + RuntimeSource::Auto => vec![NUGET_FEED_INDEX, ORT_NIGHTLY_FEED_INDEX], + RuntimeSource::Nuget => vec![NUGET_FEED_INDEX], + RuntimeSource::OrtNightly => vec![ORT_NIGHTLY_FEED_INDEX], + } + } + + pub fn native_package_url(base_address: &str, package_name: &str, version: &str) -> String { + let base = if base_address.ends_with('/') { + base_address.to_string() + } else { + format!("{base_address}/") + }; + let lower_name = package_name.to_lowercase(); + let lower_version = version.to_lowercase(); + format!("{base}{lower_name}/{lower_version}/{lower_name}.{lower_version}.nupkg") + } + + pub fn runtime_dir() -> Result { + crate::persistence::foundry_native_runtime_root() + } + + pub fn runtime_ready() -> bool { + runtime_dir() + .map(|dir| required_libraries_present(&dir)) + .unwrap_or(false) + } + + pub async fn ensure_runtime(source: RuntimeSource, progress: F) -> Result + where + F: Fn(&str, f64), + { + let (windows_start, windows_end) = windows_app_runtime_progress_range(); + ensure_windows_app_runtime(&progress, windows_start, windows_end).await?; + + let target_dir = runtime_dir()?; + if required_libraries_present(&target_dir) { + progress("Foundry Local 运行组件已下载", 100.0); + return Ok(target_dir); + } + + let parent = target_dir + .parent() + .context("resolve Foundry native runtime parent")?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + + let staging_dir = parent.join(".runtime-download"); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir) + .with_context(|| format!("remove stale {}", staging_dir.display()))?; + } + fs::create_dir_all(&staging_dir) + .with_context(|| format!("create {}", staging_dir.display()))?; + + let client = reqwest::Client::new(); + let (native_start, native_end) = native_runtime_progress_range(); + let native_span = native_end - native_start; + for (index, package) in PACKAGES.iter().enumerate() { + let start_percent = native_start + (index as f64 / PACKAGES.len() as f64) * native_span; + let end_percent = + native_start + ((index + 1) as f64 / PACKAGES.len() as f64) * native_span; + let label = format!("下载 Foundry 运行组件:{}", package.name); + progress(&label, start_percent); + + download_package_with_fallbacks(&client, package, source, &staging_dir).await?; + + if !staging_dir.join(package.expected_file).exists() { + anyhow::bail!( + "Foundry 运行组件缺少 {} from {} {}", + package.expected_file, + package.name, + package.version + ); + } + progress(&label, end_percent); + } + + if !required_libraries_present(&staging_dir) { + anyhow::bail!("Foundry 运行组件下载不完整"); + } + + if target_dir.exists() { + fs::remove_dir_all(&target_dir) + .with_context(|| format!("remove {}", target_dir.display()))?; + } + fs::rename(&staging_dir, &target_dir).with_context(|| { + format!("move {} to {}", staging_dir.display(), target_dir.display()) + })?; + progress("Foundry Local 运行组件已下载", 100.0); + Ok(target_dir) + } + + fn windows_app_runtime_progress_range() -> (f64, f64) { + ( + WINDOWS_APP_RUNTIME_PROGRESS_START, + WINDOWS_APP_RUNTIME_PROGRESS_END, + ) + } + + fn native_runtime_progress_range() -> (f64, f64) { + (NATIVE_RUNTIME_PROGRESS_START, NATIVE_RUNTIME_PROGRESS_END) + } + + async fn download_package_with_fallbacks( + client: &reqwest::Client, + package: &NativePackage, + source: RuntimeSource, + out_dir: &Path, + ) -> Result<()> { + let mut last_error: Option = None; + for feed_index in feed_indexes_for_runtime_source(source) { + match download_package_from_feed(client, package, feed_index, out_dir).await { + Ok(()) => return Ok(()), + Err(error) => { + log::warn!( + "[foundry-asr] download {} {} from {} failed: {error:#}", + package.name, + package.version, + feed_index + ); + last_error = Some(error); + } + } + } + + match last_error { + Some(error) => Err(error).with_context(|| { + format!( + "下载 Foundry 运行组件失败:{} {}", + package.name, package.version + ) + }), + None => anyhow::bail!("没有可用的 Foundry 运行组件下载源"), + } + } + + async fn download_package_from_feed( + client: &reqwest::Client, + package: &NativePackage, + feed_index: &str, + out_dir: &Path, + ) -> Result<()> { + let base_address = resolve_package_base_address(client, feed_index).await?; + let url = native_package_url(&base_address, package.name, package.version); + let bytes = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("HTTP error for {url}"))? + .bytes() + .await + .with_context(|| format!("read {url}"))?; + + let nupkg_path = out_dir.join(format!( + "{}-{}.nupkg", + package.name.to_lowercase(), + package.version + )); + fs::write(&nupkg_path, &bytes) + .with_context(|| format!("write {}", nupkg_path.display()))?; + let extracted = extract_native_libraries_from_nupkg(&nupkg_path, out_dir)?; + fs::remove_file(&nupkg_path).ok(); + if extracted == 0 { + anyhow::bail!("NuGet 包里没有找到 win-x64 native DLL: {url}"); + } + Ok(()) + } + + async fn ensure_windows_app_runtime( + progress: &F, + start_percent: f64, + end_percent: f64, + ) -> Result<()> + where + F: Fn(&str, f64), + { + let before_probe = run_windows_app_runtime_probe().await; + if matches!(before_probe.as_ref(), Ok(capture) if capture.success()) { + return Ok(()); + } + log_windows_app_runtime_probe("before install", &before_probe); + + progress( + "下载 Windows App Runtime 1.8(Foundry Local 依赖)", + start_percent, + ); + let installer_dir = crate::persistence::foundry_local_root()?.join("windows-app-runtime"); + fs::create_dir_all(&installer_dir) + .with_context(|| format!("create {}", installer_dir.display()))?; + let installer_path = installer_dir.join(WINDOWS_APP_RUNTIME_INSTALLER_FILE); + + let bytes = reqwest::Client::new() + .get(WINDOWS_APP_RUNTIME_INSTALLER_URL) + .send() + .await + .context("download Windows App Runtime installer")? + .error_for_status() + .context("download Windows App Runtime installer failed")? + .bytes() + .await + .context("read Windows App Runtime installer")?; + fs::write(&installer_path, &bytes) + .with_context(|| format!("write {}", installer_path.display()))?; + + progress( + "安装 Windows App Runtime 1.8", + (start_percent + end_percent) / 2.0, + ); + let install_path = installer_path.clone(); + let install_output = task::spawn_blocking(move || { + let mut command = Command::new(&install_path); + command.args(["--quiet", "--force"]); + command.creation_flags(CREATE_NO_WINDOW); + command.output() + }) + .await + .context("join Windows App Runtime installer task")? + .with_context(|| format!("run {}", installer_path.display()))?; + fs::remove_file(&installer_path).ok(); + + let install_capture = CommandCapture { + status_code: install_output.status.code(), + stdout: decode_command_output(&install_output.stdout), + stderr: decode_command_output(&install_output.stderr), + }; + log_command_capture("Windows App Runtime installer", &install_capture); + + if !wait_for_windows_app_runtime_ready().await { + let after_probe = run_windows_app_runtime_probe().await; + log_windows_app_runtime_probe("after install", &after_probe); + if install_capture.success() { + log::warn!( + "[foundry-asr] Windows App Runtime installer exited successfully, but the package probe did not confirm the complete current-user package set. Continuing with the Foundry SDK bootstrapper disabled; if Foundry fails later, use the probe diagnostics in openless.log. {}", + WINDOWS_APP_RUNTIME_MANUAL_STEPS + ); + progress( + "Windows App Runtime 1.8 安装器已完成(验证结果见日志)", + end_percent, + ); + return Ok(()); + } + anyhow::bail!( + "Windows App Runtime 1.8 is still unavailable after automatic installation. OpenLess stopped before starting Foundry Local to avoid the Windows bootstrapper dialog. Installer exit code: {}. {} See openless.log for installer output, package list, OS build, and elevation diagnostics.", + format_exit_code(install_capture.status_code), + WINDOWS_APP_RUNTIME_MANUAL_STEPS + ); + } + progress("Windows App Runtime 1.8 已安装", end_percent); + Ok(()) + } + + async fn wait_for_windows_app_runtime_ready() -> bool { + for _ in 0..20 { + if windows_app_runtime_ready().await { + return true; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + false + } + + fn windows_app_runtime_detection_script() -> &'static str { + r#" +$min = [version]'1.8.1.0' +$pkgs = @(Get-AppxPackage -Name '*AppRuntime*' -ErrorAction SilentlyContinue) +function Test-Package($NamePattern, $Architecture, $IsFramework) { + return @($pkgs | Where-Object { + $_.Name -match $NamePattern ` + -and "$($_.Architecture)" -ieq $Architecture ` + -and [version]$_.Version -ge $min ` + -and "$($_.Status)" -eq 'Ok' ` + -and $_.IsPartiallyStaged -ne $true ` + -and ([bool]$_.IsFramework -eq $IsFramework) + }).Count -gt 0 +} +$checks = [ordered]@{ + frameworkX86 = Test-Package '^Microsoft\.WindowsAppRuntime\.1\.8$' 'X86' $true + frameworkX64 = Test-Package '^Microsoft\.WindowsAppRuntime\.1\.8$' 'X64' $true + mainX64 = Test-Package '^MicrosoftCorporationII\.(WinAppRuntime|WindowsAppRuntime)\.Main\.1\.8$' 'X64' $false + singletonX64 = Test-Package '^(MicrosoftCorporationII\.(WinAppRuntime|WindowsAppRuntime)\.Singleton|Microsoft\.WindowsAppRuntime\.Singleton)$' 'X64' $false + ddlmX86 = Test-Package '^Microsoft\.WinAppRuntime\.DDLM\..*-x8$' 'X86' $false + ddlmX64 = Test-Package '^Microsoft\.WinAppRuntime\.DDLM\..*-x6$' 'X64' $false +} +$readyForFoundryX64 = $checks.frameworkX64 -and $checks.mainX64 -and $checks.singletonX64 -and $checks.ddlmX64 +$completeX64MachineRuntime = $checks.frameworkX86 -and $readyForFoundryX64 -and $checks.ddlmX86 +$isElevated = $false +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $isElevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} catch {} +$allUsersPackages = @() +$allUsersError = $null +try { + $allUsersPackages = @(Get-AppxPackage -AllUsers -Name '*AppRuntime*' -ErrorAction Stop | Select-Object -First 80 Name, Architecture, Version, PackageFamilyName, PackageFullName, IsFramework, IsPartiallyStaged, SignatureKind, Status) +} catch { + $allUsersError = $_.Exception.Message +} +$diagnostics = [ordered]@{ + ready = $readyForFoundryX64 + readyForFoundryX64 = $readyForFoundryX64 + completeX64MachineRuntime = $completeX64MachineRuntime + minMsixPackageVersion = $min.ToString() + osVersion = [Environment]::OSVersion.Version.ToString() + is64BitOperatingSystem = [Environment]::Is64BitOperatingSystem + is64BitProcess = [Environment]::Is64BitProcess + processArchitecture = $env:PROCESSOR_ARCHITECTURE + processArchitectureW6432 = $env:PROCESSOR_ARCHITEW6432 + isElevated = $isElevated + checks = $checks + currentUserPackages = @($pkgs | Select-Object -First 80 Name, Architecture, Version, PackageFamilyName, PackageFullName, IsFramework, IsPartiallyStaged, SignatureKind, Status) + allUsersPackages = $allUsersPackages + allUsersError = $allUsersError +} +$diagnostics | ConvertTo-Json -Compress -Depth 6 +if ($readyForFoundryX64) { exit 0 } else { exit 1 } +"# + } + + pub async fn windows_app_runtime_ready() -> bool { + run_windows_app_runtime_probe() + .await + .map(|capture| capture.success()) + .unwrap_or(false) + } + + async fn run_windows_app_runtime_probe() -> Result { + run_powershell_script(windows_app_runtime_detection_script()).await + } + + async fn run_powershell_script(script: &'static str) -> Result { + task::spawn_blocking(move || { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]); + command.creation_flags(CREATE_NO_WINDOW); + let output = command.output().context("run powershell.exe")?; + Ok(CommandCapture { + status_code: output.status.code(), + stdout: decode_command_output(&output.stdout), + stderr: decode_command_output(&output.stderr), + }) + }) + .await + .context("join PowerShell task")? + } + + fn decode_command_output(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() + } + + fn log_windows_app_runtime_probe(stage: &str, result: &Result) { + match result { + Ok(capture) => { + log_command_capture(&format!("Windows App Runtime probe ({stage})"), capture) + } + Err(error) => { + log::warn!("[foundry-asr] Windows App Runtime probe ({stage}) failed: {error:#}"); + } + } + } + + fn log_command_capture(label: &str, capture: &CommandCapture) { + let level = if capture.success() { + log::Level::Info + } else { + log::Level::Warn + }; + log::log!( + level, + "[foundry-asr] {label} exited code={} stdout={} stderr={}", + format_exit_code(capture.status_code), + compact_log_text(&capture.stdout), + compact_log_text(&capture.stderr) + ); + } + + fn format_exit_code(code: Option) -> String { + match code { + Some(value) => format!("0x{:08X}", value as u32), + None => "unknown".to_string(), + } + } + + fn compact_log_text(value: &str) -> String { + const MAX_LOG_CHARS: usize = 6000; + let compact = value.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= MAX_LOG_CHARS { + compact + } else { + let truncated: String = compact.chars().take(MAX_LOG_CHARS).collect(); + format!("{truncated}...(truncated)") + } + } + + async fn resolve_package_base_address( + client: &reqwest::Client, + feed_index: &str, + ) -> Result { + let value: Value = client + .get(feed_index) + .send() + .await + .with_context(|| format!("GET {feed_index}"))? + .error_for_status() + .with_context(|| format!("HTTP error for {feed_index}"))? + .json() + .await + .with_context(|| format!("decode NuGet feed index {feed_index}"))?; + + let resources = value["resources"] + .as_array() + .context("NuGet feed index missing resources")?; + for resource in resources { + let resource_type = resource["@type"].as_str().unwrap_or(""); + if resource_type == "PackageBaseAddress/3.0.0" { + if let Some(id) = resource["@id"].as_str() { + return Ok(id.to_string()); + } + } + } + anyhow::bail!("NuGet feed index missing PackageBaseAddress: {feed_index}"); + } + + fn required_libraries_present(dir: &Path) -> bool { + REQUIRED_DLLS.iter().all(|dll| dir.join(dll).exists()) + } + + pub fn extract_native_libraries_from_nupkg(nupkg: &Path, out_dir: &Path) -> Result { + let bytes = fs::read(nupkg).with_context(|| format!("read {}", nupkg.display()))?; + extract_native_libraries(io::Cursor::new(bytes), out_dir) + } + + fn extract_native_libraries(reader: R, out_dir: &Path) -> Result { + let mut archive = zip::ZipArchive::new(reader).context("open nupkg zip")?; + let mut extracted = 0usize; + for index in 0..archive.len() { + let mut file = archive.by_index(index).context("read zip entry")?; + let name = file.name().to_string(); + if !name.starts_with(TARGET_RID_NATIVE_PREFIX) || !name.ends_with(".dll") { + continue; + } + let Some(file_name) = Path::new(&name).file_name() else { + continue; + }; + let dest = out_dir.join(file_name); + let mut out_file = + fs::File::create(&dest).with_context(|| format!("create {}", dest.display()))?; + io::copy(&mut file, &mut out_file) + .with_context(|| format!("write {}", dest.display()))?; + extracted += 1; + } + Ok(extracted) + } + + #[cfg(test)] + mod tests { + use std::fs; + use std::io::Write; + use zip::write::SimpleFileOptions; + + use super::super::{ + extract_native_libraries_from_nupkg, feed_indexes_for_runtime_source, + native_package_url, normalize_runtime_source, RuntimeSource, + }; + + #[test] + fn runtime_source_auto_tries_nuget_before_ort_nightly() { + assert_eq!(normalize_runtime_source(""), RuntimeSource::Auto); + assert_eq!(normalize_runtime_source("unknown"), RuntimeSource::Auto); + + let feeds = feed_indexes_for_runtime_source(RuntimeSource::Auto); + + assert_eq!(feeds[0], "https://api.nuget.org/v3/index.json"); + assert_eq!( + feeds[1], + "https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json" + ); + } + + #[test] + fn native_package_url_uses_lowercase_nuget_id_and_version() { + let url = native_package_url( + "https://example.test/packages/", + "Microsoft.AI.Foundry.Local.Core.WinML", + "1.0.0", + ); + + assert_eq!( + url, + "https://example.test/packages/microsoft.ai.foundry.local.core.winml/1.0.0/microsoft.ai.foundry.local.core.winml.1.0.0.nupkg" + ); + } + + #[test] + fn windows_app_runtime_detection_requires_complete_package_set() { + let script = super::windows_app_runtime_detection_script(); + + assert!(script.contains("Microsoft.WindowsAppRuntime.1.8")); + assert!(script.contains("frameworkX86")); + assert!(script.contains("frameworkX64")); + assert!(script.contains("readyForFoundryX64")); + assert!(script.contains("completeX64MachineRuntime")); + assert!(script.contains("Main.1.8")); + assert!(script.contains("Singleton")); + assert!(script.contains("ddlmX86")); + assert!(script.contains("ddlmX64")); + assert!(script.contains("1.8.1.0")); + assert!(script.contains("PackageFamilyName")); + assert!(script.contains("IsFramework")); + } + + #[test] + fn runtime_prepare_progress_starts_with_windows_app_runtime() { + let windows_runtime = super::windows_app_runtime_progress_range(); + let native_runtime = super::native_runtime_progress_range(); + + assert_eq!(windows_runtime, (0.0, 20.0)); + assert!(native_runtime.0 >= windows_runtime.1); + assert_eq!(native_runtime.1, 98.0); + } + + #[test] + fn extract_native_libraries_only_keeps_target_rid_dlls() { + let root = std::env::temp_dir().join(format!( + "openless-foundry-native-zip-test-{}", + uuid::Uuid::new_v4() + )); + let nupkg = root.join("package.nupkg"); + let out = root.join("out"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&out).unwrap(); + + { + let file = fs::File::create(&nupkg).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default(); + zip.start_file( + "runtimes/win-x64/native/Microsoft.AI.Foundry.Local.Core.dll", + options, + ) + .unwrap(); + zip.write_all(b"core").unwrap(); + zip.start_file("runtimes/linux-x64/native/libignored.so", options) + .unwrap(); + zip.write_all(b"ignored").unwrap(); + zip.start_file("tools/not-native.dll", options).unwrap(); + zip.write_all(b"ignored").unwrap(); + zip.finish().unwrap(); + } + + let extracted = extract_native_libraries_from_nupkg(&nupkg, &out).unwrap(); + + assert_eq!(extracted, 1); + assert!(out.join("Microsoft.AI.Foundry.Local.Core.dll").exists()); + assert!(!out.join("libignored.so").exists()); + assert!(!out.join("not-native.dll").exists()); + + fs::remove_dir_all(root).unwrap(); + } + } +} + +#[cfg(target_os = "windows")] +pub use imp::*; + +#[cfg(not(target_os = "windows"))] +pub fn normalize_runtime_source_str(value: &str) -> String { + match value.trim() { + "nuget" => "nuget".into(), + "ort-nightly" => "ort-nightly".into(), + _ => "auto".into(), + } +} diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index 830b1ad6..1e2ac7db 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -27,6 +27,7 @@ pub struct FoundryLocalWhisperAsr { #[cfg(target_os = "windows")] runtime: Arc, model_alias: String, + runtime_source: String, language_hint: Option, buffer: Mutex>, cancel_generation: AtomicU64, @@ -37,11 +38,13 @@ impl FoundryLocalWhisperAsr { pub fn new( runtime: Arc, model_alias: String, + runtime_source: String, language_hint: Option, ) -> Self { Self { runtime, model_alias, + runtime_source, language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), cancel_generation: AtomicU64::new(0), @@ -52,6 +55,7 @@ impl FoundryLocalWhisperAsr { pub fn new(model_alias: String, language_hint: Option) -> Self { Self { model_alias, + runtime_source: "auto".into(), language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), cancel_generation: AtomicU64::new(0), @@ -109,6 +113,7 @@ impl FoundryLocalWhisperAsr { .runtime .transcribe_audio_file( &self.model_alias, + &self.runtime_source, self.language_hint(), wav_file.path(), audio_timeout, @@ -259,6 +264,7 @@ mod tests { super::FoundryLocalWhisperAsr::new( Arc::clone(&runtime), "whisper-small".into(), + "auto".into(), Some(" zh ".into()), ), runtime, diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index d3b4fece..7a762fcb 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -1,7 +1,7 @@ #[cfg(target_os = "windows")] #[allow(dead_code)] mod imp { - use std::path::Path; + use std::path::{Path, PathBuf}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -16,6 +16,7 @@ mod imp { FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, MODELS, PROVIDER_ID, }; + use crate::asr::local::foundry_native::{self, RuntimeSource}; type FoundryPrepareProgressCallback = Arc; @@ -54,36 +55,39 @@ mod imp { } } - pub fn status_snapshot(&self, active_model: &str) -> FoundryRuntimeStatus { - match self.manager() { - Ok(_) => { - let state = self.state.lock(); - FoundryRuntimeStatus { - provider_id: PROVIDER_ID.into(), - available: true, - active_model: active_model.to_string(), - loaded_model_id: state - .loaded - .as_ref() - .map(|loaded| loaded.model_id.clone()), - endpoint: None, - error: None, - } - } - Err(error) => FoundryRuntimeStatus::unavailable( - active_model.to_string(), - format!("Foundry Local runtime unavailable: {error:#}"), - ), + pub async fn status_snapshot( + &self, + active_model: &str, + runtime_source: &str, + ) -> FoundryRuntimeStatus { + let loaded_model_id = self + .state + .lock() + .loaded + .as_ref() + .map(|loaded| loaded.model_id.clone()); + let native_ready = foundry_native::runtime_ready(); + FoundryRuntimeStatus { + provider_id: PROVIDER_ID.into(), + available: true, + runtime_ready: native_ready, + runtime_source: foundry_native::normalize_runtime_source_str(runtime_source), + active_model: active_model.to_string(), + loaded_model_id, + endpoint: None, + error: None, } } - pub async fn ensure_loaded(&self, alias: &str) -> Result { - self.ensure_loaded_with_progress(alias, |_| {}).await + pub async fn ensure_loaded(&self, alias: &str, runtime_source: &str) -> Result { + self.ensure_loaded_with_progress(alias, runtime_source, |_| {}) + .await } pub async fn ensure_loaded_with_progress( &self, alias: &str, + runtime_source: &str, progress: F, ) -> Result where @@ -92,7 +96,11 @@ mod imp { let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); let progress: FoundryPrepareProgressCallback = Arc::new(progress); - Ok(self.ensure_loaded_locked(alias, progress).await?.model_id) + let runtime_source = foundry_native::normalize_runtime_source(runtime_source); + Ok(self + .ensure_loaded_locked(alias, runtime_source, progress) + .await? + .model_id) } pub fn request_cancel_prepare(&self) { @@ -106,6 +114,9 @@ mod imp { pub async fn catalog_snapshot(&self) -> Result> { let _lifecycle = self.lifecycle.lock().await; + if !foundry_native::runtime_ready() { + return Ok(crate::asr::local::foundry::static_catalog_models()); + } let manager = self.manager()?; let mut catalog = Vec::with_capacity(MODELS.len()); for known in MODELS { @@ -132,14 +143,16 @@ mod imp { pub async fn transcribe_audio_file( &self, alias: &str, + runtime_source: &str, language_hint: Option<&str>, audio_path: &Path, audio_timeout: std::time::Duration, ) -> Result { let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); + let runtime_source = foundry_native::normalize_runtime_source(runtime_source); let model = self - .ensure_loaded_locked(alias, Arc::new(|_| {})) + .ensure_loaded_locked(alias, runtime_source, Arc::new(|_| {})) .await? .model; let mut client = model.create_audio_client(); @@ -166,6 +179,7 @@ mod imp { async fn ensure_loaded_locked( &self, alias: &str, + runtime_source: RuntimeSource, progress: FoundryPrepareProgressCallback, ) -> Result { if let Some(loaded) = self.cached_loaded_model(alias) { @@ -178,6 +192,20 @@ mod imp { let previous_loaded = self.loaded_for_different_alias(alias); + self.check_prepare_cancelled()?; + foundry_native::ensure_runtime(runtime_source, { + let progress = Arc::clone(&progress); + let alias = alias.to_string(); + move |label, percent| { + progress.as_ref()(FoundryPrepareProgressPayload::runtime( + alias.clone(), + label.to_string(), + percent, + )); + } + }) + .await + .context("download Foundry Local native runtime")?; self.check_prepare_cancelled()?; let manager = self.manager()?; progress.as_ref()(FoundryPrepareProgressPayload::runtime( @@ -404,13 +432,33 @@ mod imp { return Ok(manager); } - let manager = - FoundryLocalManager::create(FoundryLocalConfig::new("foundry_local_samples")) - .context("initialize Foundry Local manager")?; + let manager = FoundryLocalManager::create(self.manager_config()) + .context("initialize Foundry Local manager")?; self.state.lock().manager = Some(manager); Ok(manager) } + fn manager_config(&self) -> FoundryLocalConfig { + // OpenLess owns Windows App Runtime installation; keep the SDK bootstrapper non-interactive. + let mut config = + FoundryLocalConfig::new("openless").additional_setting("Bootstrap", "false"); + if let Ok(dir) = crate::persistence::foundry_app_data_root() { + config = config.app_data_dir(dir.to_string_lossy().to_string()); + } + if let Ok(dir) = crate::persistence::foundry_model_cache_root() { + config = config.model_cache_dir(dir.to_string_lossy().to_string()); + } + if let Ok(dir) = crate::persistence::foundry_logs_root() { + config = config.logs_dir(dir.to_string_lossy().to_string()); + } + let runtime_dir = foundry_native::runtime_dir().ok(); + let candidates = foundry_native_dir_candidates(runtime_dir.as_deref()); + if let Some(native_dir) = select_foundry_native_dir(candidates) { + config = config.library_path(native_dir.to_string_lossy().to_string()); + } + config + } + fn loaded_model_snapshot(&self) -> Option { self.state.lock().loaded.clone() } @@ -467,9 +515,29 @@ mod imp { .map(str::to_string) } + fn foundry_native_dir_candidates(runtime_dir: Option<&Path>) -> Vec { + let mut candidates = Vec::new(); + + if let Some(runtime_dir) = runtime_dir { + candidates.push(runtime_dir.to_path_buf()); + } + + candidates + } + + fn select_foundry_native_dir(candidates: Vec) -> Option { + candidates + .into_iter() + .find(|dir| dir.join("Microsoft.AI.Foundry.Local.Core.dll").exists()) + } + #[cfg(test)] mod lifecycle_tests { - use super::{normalized_language_hint, FoundryLocalRuntime}; + use super::{ + foundry_native_dir_candidates, normalized_language_hint, select_foundry_native_dir, + FoundryLocalRuntime, + }; + use std::fs; #[test] fn runtime_has_async_lifecycle_gate() { @@ -487,6 +555,27 @@ mod imp { assert_eq!(normalized_language_hint(Some("")), None); assert_eq!(normalized_language_hint(None), None); } + + #[test] + fn runtime_finds_downloaded_foundry_native_runtime_dir() { + let root = std::env::temp_dir().join(format!( + "openless-foundry-native-test-{}", + uuid::Uuid::new_v4() + )); + let native_dir = root.join("runtime"); + fs::create_dir_all(&native_dir).unwrap(); + fs::write( + native_dir.join("Microsoft.AI.Foundry.Local.Core.dll"), + b"placeholder", + ) + .unwrap(); + + let candidates = foundry_native_dir_candidates(Some(native_dir.as_path())); + + assert_eq!(select_foundry_native_dir(candidates).unwrap(), native_dir); + + fs::remove_dir_all(root).unwrap(); + } } } @@ -509,20 +598,31 @@ impl FoundryLocalRuntime { Self } - pub fn status_snapshot(&self, active_model: &str) -> super::foundry::FoundryRuntimeStatus { - super::foundry::FoundryRuntimeStatus::unavailable( + pub async fn status_snapshot( + &self, + active_model: &str, + runtime_source: &str, + ) -> super::foundry::FoundryRuntimeStatus { + let mut status = super::foundry::FoundryRuntimeStatus::unavailable( active_model.to_string(), "Foundry Local Whisper is only available on Windows", - ) + ); + status.runtime_source = super::foundry_native::normalize_runtime_source_str(runtime_source); + status } - pub async fn ensure_loaded(&self, alias: &str) -> anyhow::Result { + pub async fn ensure_loaded( + &self, + alias: &str, + _runtime_source: &str, + ) -> anyhow::Result { anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); } pub async fn ensure_loaded_with_progress( &self, alias: &str, + _runtime_source: &str, _progress: F, ) -> anyhow::Result where @@ -542,6 +642,7 @@ impl FoundryLocalRuntime { pub async fn transcribe_audio_file( &self, alias: &str, + _runtime_source: &str, _language_hint: Option<&str>, _audio_path: &std::path::Path, _audio_timeout: std::time::Duration, @@ -558,10 +659,10 @@ impl FoundryLocalRuntime { mod tests { use super::FoundryLocalRuntime; - #[test] - fn new_runtime_reports_native_audio_status_shape() { + #[tokio::test] + async fn new_runtime_reports_native_audio_status_shape() { let runtime = FoundryLocalRuntime::new(); - let status = runtime.status_snapshot("whisper-small"); + let status = runtime.status_snapshot("whisper-small", "auto").await; assert_eq!(status.provider_id, crate::asr::local::foundry::PROVIDER_ID); assert_eq!(status.active_model, "whisper-small"); @@ -580,7 +681,7 @@ mod tests { runtime.release_now().await.unwrap(); - let status = runtime.status_snapshot("whisper-small"); + let status = runtime.status_snapshot("whisper-small", "auto").await; assert_eq!(status.loaded_model_id, None); } } diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 98ae85b5..723d0054 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -6,6 +6,7 @@ pub mod cache; pub mod download; pub mod foundry; +pub mod foundry_native; pub mod foundry_provider; pub mod foundry_runtime; mod local_provider; diff --git a/openless-all/app/src-tauri/src/audio_mute.rs b/openless-all/app/src-tauri/src/audio_mute.rs index 76be9bb7..c05087d1 100644 --- a/openless-all/app/src-tauri/src/audio_mute.rs +++ b/openless-all/app/src-tauri/src/audio_mute.rs @@ -23,8 +23,10 @@ impl Drop for AudioMuteGuard { #[cfg(target_os = "windows")] mod platform { use windows::Win32::Foundation::{BOOL, RPC_E_CHANGED_MODE}; - use windows::Win32::Media::Audio::{eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator}; use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; use windows::Win32::System::Com::{ CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, }; diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 62af5d8d..60e24b88 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -1582,14 +1582,20 @@ fn normalize_foundry_language_hint(language_hint: &str) -> Result String { + crate::asr::local::foundry_native::normalize_runtime_source_str(source) +} + #[tauri::command] -pub fn foundry_local_asr_status( +pub async fn foundry_local_asr_status( coord: CoordinatorState<'_>, runtime: State<'_, Arc>, -) -> FoundryRuntimeStatus { +) -> Result { let prefs = coord.prefs().get(); let active_model = active_foundry_model_from_prefs(&prefs); - runtime.status_snapshot(&active_model) + Ok(runtime + .status_snapshot(&active_model, &prefs.foundry_local_runtime_source) + .await) } #[tauri::command] @@ -1624,16 +1630,29 @@ pub fn foundry_local_asr_set_language_hint( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn foundry_local_asr_set_runtime_source( + coord: CoordinatorState<'_>, + source: String, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + prefs.foundry_local_runtime_source = normalize_foundry_runtime_source(&source); + coord.prefs().set(prefs).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn foundry_local_asr_prepare( app: AppHandle, + coord: CoordinatorState<'_>, runtime: State<'_, Arc>, model_alias: String, ) -> Result { validate_foundry_model_alias(&model_alias)?; + let prefs = coord.prefs().get(); + let runtime_source = prefs.foundry_local_runtime_source.clone(); let progress_app = app.clone(); let result = runtime - .ensure_loaded_with_progress(&model_alias, move |payload| { + .ensure_loaded_with_progress(&model_alias, &runtime_source, move |payload| { emit_foundry_prepare_progress(&progress_app, payload); }) .await; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 62998908..d34c2605 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2044,6 +2044,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let local = Arc::new(FoundryLocalWhisperAsr::new( Arc::clone(&inner.foundry_local_runtime), model_alias, + prefs.foundry_local_runtime_source.clone(), language_hint, )); store_asr_for_session( @@ -4096,6 +4097,7 @@ mod tests { let provider = Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( Arc::new(crate::asr::local::FoundryLocalRuntime::new()), crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.to_string(), + "auto".to_string(), None, )); let active_asr = ActiveAsr::FoundryLocalWhisper(provider); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index fe4e9708..a7797680 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -182,24 +182,19 @@ pub fn run() { .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => show_main_window(app), "quit" => app.exit(0), - id => handle_microphone_tray_menu_event( - app, - id, - ), + id => handle_microphone_tray_menu_event(app, id), }) - .on_tray_icon_event(move |tray, event| { - match event { - TrayIconEvent::Enter { .. } => { - if let Err(err) = refresh_tray_microphone_menu(tray.app_handle()) { - log::warn!("[tray] refresh microphone menu on hover failed: {err}"); - } + .on_tray_icon_event(move |tray, event| match event { + TrayIconEvent::Enter { .. } => { + if let Err(err) = refresh_tray_microphone_menu(tray.app_handle()) { + log::warn!("[tray] refresh microphone menu on hover failed: {err}"); } - TrayIconEvent::Click { - button: MouseButton::Left, - .. - } => show_main_window(tray.app_handle()), - _ => {} } + TrayIconEvent::Click { + button: MouseButton::Left, + .. + } => show_main_window(tray.app_handle()), + _ => {} }) .build(app)?; start_tray_microphone_watcher(app.handle().clone()); @@ -291,6 +286,7 @@ pub fn run() { commands::foundry_local_asr_catalog, commands::foundry_local_asr_set_model, commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_set_runtime_source, commands::foundry_local_asr_prepare, commands::foundry_local_asr_cancel_prepare, commands::foundry_local_asr_release, @@ -384,8 +380,8 @@ fn build_microphone_tray_menu>( Vec::new() } }; - let selected_available = selected.trim().is_empty() - || devices.iter().any(|device| device.name == selected); + let selected_available = + selected.trim().is_empty() || devices.iter().any(|device| device.name == selected); let default_item = CheckMenuItemBuilder::with_id("mic-default", "系统默认麦克风") .checked(selected.trim().is_empty() || !selected_available) @@ -403,23 +399,23 @@ fn build_microphone_tray_menu>( .build(app)?; submenu = submenu.item(&empty); } else { - for (index, device) in devices.into_iter().enumerate() { - let id = format!("mic-device-{index}"); - let label = if device.is_default { - format!("{}(系统默认)", device.name) - } else { - device.name.clone() - }; - let item = CheckMenuItemBuilder::with_id(&id, label) - .checked(selected == device.name) - .build(app)?; - submenu = submenu.item(&item); - items.push(commands::TrayMicrophoneMenuItem { - id, - device_name: device.name, - item, - }); - } + for (index, device) in devices.into_iter().enumerate() { + let id = format!("mic-device-{index}"); + let label = if device.is_default { + format!("{}(系统默认)", device.name) + } else { + device.name.clone() + }; + let item = CheckMenuItemBuilder::with_id(&id, label) + .checked(selected == device.name) + .build(app)?; + submenu = submenu.item(&item); + items.push(commands::TrayMicrophoneMenuItem { + id, + device_name: device.name, + item, + }); + } } Ok(MicrophoneTrayMenu { @@ -474,20 +470,20 @@ fn start_tray_microphone_watcher(app: AppHandle) { let refresh_app = app.clone(); let _ = app.run_on_main_thread(move || { if let Err(err) = refresh_tray_microphone_menu(&refresh_app) { - log::warn!("[tray] refresh microphone menu after device change failed: {err}"); + log::warn!( + "[tray] refresh microphone menu after device change failed: {err}" + ); } let _ = refresh_app.emit("microphone:devices-changed", serde_json::json!({})); }); } - }) { + }) + { log::warn!("[tray] start microphone watcher failed: {err}"); } } -fn handle_microphone_tray_menu_event( - app: &AppHandle, - id: &str, -) { +fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) { let tray_items = app.state::(); let items = tray_items.lock(); let Some(selected) = items.iter().find(|item| item.id == id) else { diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f559b4a7..4970a7d7 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -94,6 +94,43 @@ pub fn local_models_root() -> Result { Ok(dir) } +/// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR +/// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 +#[cfg(target_os = "windows")] +pub fn foundry_local_root() -> Result { + let dir = data_dir()?.join("models").join("foundry-local"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_native_runtime_root() -> Result { + let dir = foundry_local_root()?.join("runtime"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_model_cache_root() -> Result { + let dir = foundry_local_root()?; + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_app_data_root() -> Result { + let dir = foundry_local_root()?.join("app-data"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_logs_root() -> Result { + let dir = foundry_local_root()?.join("logs"); + ensure_dir(&dir)?; + Ok(dir) +} + /// Atomic write: write to `*.tmp` first, then rename onto the target path. fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { if let Some(parent) = path.parent() { diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 9668b0ba..1f767cd9 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -10,9 +10,7 @@ use std::time::Duration; use serde_json::{json, Value}; use thiserror::Error; -use crate::types::{ - ChineseScriptPreference, OutputLanguagePreference, PolishMode, QaChatMessage, -}; +use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode, QaChatMessage}; const DEFAULT_TEMPERATURE: f32 = 0.3; const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; @@ -97,14 +95,12 @@ impl OpenAICompatibleLLMProvider { front_app: Option<&str>, ) -> Result { let mut system_prompt = compose_system_prompt(mode, hotwords); - if let Some(premise) = - context_premise( - working_languages, - chinese_script_preference, - output_language_preference, - front_app, - ) - { + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { system_prompt = format!("{}\n\n{}", premise, system_prompt); } let user_prompt = prompts::user_prompt(raw_text); @@ -130,14 +126,12 @@ impl OpenAICompatibleLLMProvider { C: Fn() -> bool + Send + Sync, { let mut system_prompt = prompts::qa_system_prompt(); - if let Some(premise) = - context_premise( - working_languages, - chinese_script_preference, - output_language_preference, - front_app, - ) - { + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { system_prompt = format!("{}\n\n{}", premise, system_prompt); } self.chat_completion_history_streaming(&system_prompt, messages, on_delta, should_cancel) @@ -158,14 +152,12 @@ impl OpenAICompatibleLLMProvider { let mut system_prompt = prompts::translate_system_prompt(target_language); // 翻译模式必须以 target_language 为唯一输出语言约束,避免和 UI 驱动的 // output_language_preference 发生冲突(例如 UI=ja, target=en)。 - if let Some(premise) = - context_premise( - working_languages, - chinese_script_preference, - OutputLanguagePreference::Auto, - front_app, - ) - { + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + OutputLanguagePreference::Auto, + front_app, + ) { system_prompt = format!("{}\n\n{}", premise, system_prompt); } let user_prompt = prompts::user_prompt(raw_text); @@ -441,12 +433,14 @@ fn context_premise( OutputLanguagePreference::ZhTw => { Some("最終輸出語言偏好:繁體中文。若回答可用中文表達,請優先使用繁體中文。".to_string()) } - OutputLanguagePreference::En => { - Some("Output language preference: English. Prefer English when producing the final answer.".to_string()) - } - OutputLanguagePreference::Ja => { - Some("出力言語の優先設定:日本語。最終回答は可能な限り日本語で出力してください。".to_string()) - } + OutputLanguagePreference::En => Some( + "Output language preference: English. Prefer English when producing the final answer." + .to_string(), + ), + OutputLanguagePreference::Ja => Some( + "出力言語の優先設定:日本語。最終回答は可能な限り日本語で出力してください。" + .to_string(), + ), OutputLanguagePreference::Ko => { Some("출력 언어 선호: 한국어. 최종 답변은 가능하면 한국어로 작성해 주세요.".to_string()) } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 80db02a8..a74b3bc2 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -204,6 +204,9 @@ pub struct UserPreferences { /// Windows Foundry Local Whisper 当前激活的模型 alias。 #[serde(default = "default_foundry_local_asr_model")] pub foundry_local_asr_model: String, + /// Windows Foundry Local native runtime 下载源:"auto" / "nuget" / "ort-nightly"。 + #[serde(default = "default_foundry_local_runtime_source")] + pub foundry_local_runtime_source: String, /// Windows Foundry Local Whisper 语言 hint。空字符串 = 自动检测。 #[serde(default)] pub foundry_local_asr_language_hint: String, @@ -232,6 +235,10 @@ fn default_foundry_local_asr_model() -> String { crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.into() } +fn default_foundry_local_runtime_source() -> String { + "auto".into() +} + fn default_active_asr_provider() -> String { #[cfg(target_os = "windows")] { @@ -279,6 +286,8 @@ struct UserPreferencesWire { local_asr_keep_loaded_secs: u32, #[serde(default = "default_foundry_local_asr_model")] foundry_local_asr_model: String, + #[serde(default = "default_foundry_local_runtime_source")] + foundry_local_runtime_source: String, #[serde(default)] foundry_local_asr_language_hint: String, #[serde(default = "default_local_asr_keep_loaded_secs")] @@ -317,6 +326,7 @@ impl Default for UserPreferencesWire { local_asr_mirror: prefs.local_asr_mirror, local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs, foundry_local_asr_model: prefs.foundry_local_asr_model, + foundry_local_runtime_source: prefs.foundry_local_runtime_source, foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs, update_channel: prefs.update_channel, @@ -366,6 +376,10 @@ impl<'de> Deserialize<'de> for UserPreferences { local_asr_mirror: wire.local_asr_mirror, local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs, foundry_local_asr_model: wire.foundry_local_asr_model, + foundry_local_runtime_source: + crate::asr::local::foundry_native::normalize_runtime_source_str( + &wire.foundry_local_runtime_source, + ), foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs, update_channel: wire.update_channel, @@ -471,6 +485,7 @@ impl Default for UserPreferences { local_asr_mirror: default_local_asr_mirror(), local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), foundry_local_asr_model: default_foundry_local_asr_model(), + foundry_local_runtime_source: default_foundry_local_runtime_source(), foundry_local_asr_language_hint: String::new(), foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), update_channel: UpdateChannel::default(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 310d295d..6495fd18 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -602,8 +602,15 @@ export const en: typeof zhCN = { engineUnavailable: 'The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.', foundryTitle: 'Windows Foundry Local Whisper', foundryDesc: 'Windows uses Microsoft Foundry Local Whisper to recognize speech on this device with no ASR API key. First prepare downloads local runtime components and a model, then loads it. LLM polishing still uses your configured LLM provider; if none is configured, the existing raw transcript fallback still applies.', - foundryAvailable: 'Runtime available', - foundryUnavailable: 'Runtime unavailable', + foundryAvailable: 'Available on Windows', + foundryUnavailable: 'Windows only', + foundryRuntimeReady: 'Runtime components downloaded', + foundryRuntimeMissing: 'Runtime components not downloaded', + foundryRuntimeSourceLabel: 'Runtime component source', + foundryRuntimeSourceAuto: 'Auto (NuGet first)', + foundryRuntimeSourceNuget: 'NuGet official feed', + foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly feed', + foundryRuntimeSourceDesc: 'OpenLess downloads Foundry Local runtime components before preparing the first model.', foundrySelectedModel: 'Selected model', foundryActiveModel: 'Current default alias', foundryLoadedModel: 'Loaded model', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d95c18ff..4acef144 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -604,8 +604,15 @@ export const ja: typeof zhCN = { engineUnavailable: '現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。', foundryTitle: 'Windows Foundry Local Whisper', foundryDesc: 'Windows では Microsoft Foundry Local Whisper が本機上で音声を認識し、ASR API Key は不要です。初回準備ではローカル実行コンポーネントとモデルをダウンロードして読み込みます。LLM 整文は設定済みの LLM プロバイダーを引き続き使用し、未設定の場合は従来どおり生の転写結果にフォールバックします。', - foundryAvailable: 'ランタイム利用可能', - foundryUnavailable: 'ランタイム利用不可', + foundryAvailable: 'Windows で利用可能', + foundryUnavailable: 'Windows のみ対応', + foundryRuntimeReady: 'ランタイムコンポーネントはダウンロード済み', + foundryRuntimeMissing: 'ランタイムコンポーネント未ダウンロード', + foundryRuntimeSourceLabel: 'ランタイムコンポーネントの取得元', + foundryRuntimeSourceAuto: '自動(NuGet 優先)', + foundryRuntimeSourceNuget: 'NuGet 公式フィード', + foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly フィード', + foundryRuntimeSourceDesc: '初回モデル準備の前に Foundry Local ランタイムコンポーネントをダウンロードします。', foundrySelectedModel: '選択中のモデル', foundryActiveModel: '現在の既定 alias', foundryLoadedModel: '読み込み済みモデル', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index c884d308..2d280212 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -604,8 +604,15 @@ export const ko: typeof zhCN = { engineUnavailable: '현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.', foundryTitle: 'Windows Foundry Local Whisper', foundryDesc: 'Windows 는 Microsoft Foundry Local Whisper 로 이 기기에서 음성을 인식하며 ASR API Key 가 필요 없습니다. 첫 준비 시 로컬 런타임 구성 요소와 모델을 다운로드한 뒤 로드합니다. LLM 정리는 계속 설정된 LLM 공급자를 사용하며, 설정되지 않은 경우 기존 원문 전사 폴백을 그대로 사용합니다.', - foundryAvailable: '런타임 사용 가능', - foundryUnavailable: '런타임 사용 불가', + foundryAvailable: 'Windows 에서 사용 가능', + foundryUnavailable: 'Windows 전용', + foundryRuntimeReady: '런타임 구성 요소 다운로드됨', + foundryRuntimeMissing: '런타임 구성 요소 미다운로드', + foundryRuntimeSourceLabel: '런타임 구성 요소 다운로드 소스', + foundryRuntimeSourceAuto: '자동(NuGet 우선)', + foundryRuntimeSourceNuget: 'NuGet 공식 피드', + foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 피드', + foundryRuntimeSourceDesc: '첫 모델 준비 전에 Foundry Local 런타임 구성 요소를 다운로드합니다.', foundrySelectedModel: '선택한 모델', foundryActiveModel: '현재 기본 alias', foundryLoadedModel: '로드된 모델', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 09d77618..698dba4d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -600,8 +600,15 @@ export const zhCN = { engineUnavailable: '当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。', foundryTitle: 'Windows Foundry Local Whisper', foundryDesc: 'Windows 使用 Microsoft Foundry Local Whisper 在本机识别语音,无需 ASR API Key。首次准备会在本机下载运行组件和模型并加载;LLM 润色仍使用你已配置的 LLM 提供商,未配置时沿用原始转写回退。', - foundryAvailable: '运行时可用', - foundryUnavailable: '运行时不可用', + foundryAvailable: 'Windows 可用', + foundryUnavailable: '仅 Windows 可用', + foundryRuntimeReady: '运行组件已下载', + foundryRuntimeMissing: '运行组件未下载', + foundryRuntimeSourceLabel: '运行组件下载源', + foundryRuntimeSourceAuto: '自动(NuGet 优先)', + foundryRuntimeSourceNuget: 'NuGet 官方源', + foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 源', + foundryRuntimeSourceDesc: '首次准备模型前会先下载 Foundry Local 运行组件。', foundrySelectedModel: '选择模型', foundryActiveModel: '当前默认 alias', foundryLoadedModel: '已加载模型', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 1efc827f..e23d1c8e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -602,8 +602,15 @@ export const zhTW: typeof zhCN = { engineUnavailable: '當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。', foundryTitle: 'Windows Foundry Local Whisper', foundryDesc: 'Windows 使用 Microsoft Foundry Local Whisper 在本機識別語音,無需 ASR API Key。首次準備會在本機下載運行組件和模型並加載;LLM 潤色仍使用你已配置的 LLM 提供商,未配置時沿用原始轉寫回退。', - foundryAvailable: '運行時可用', - foundryUnavailable: '運行時不可用', + foundryAvailable: 'Windows 可用', + foundryUnavailable: '僅 Windows 可用', + foundryRuntimeReady: '運行組件已下載', + foundryRuntimeMissing: '運行組件未下載', + foundryRuntimeSourceLabel: '運行組件下載源', + foundryRuntimeSourceAuto: '自動(NuGet 優先)', + foundryRuntimeSourceNuget: 'NuGet 官方源', + foundryRuntimeSourceOrtNightly: 'Microsoft ORT-Nightly 源', + foundryRuntimeSourceDesc: '首次準備模型前會先下載 Foundry Local 運行組件。', foundrySelectedModel: '選擇模型', foundryActiveModel: '當前默認 alias', foundryLoadedModel: '已加載模型', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4d383f63..eab67144 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -70,6 +70,7 @@ const mockSettings: UserPreferences = { localAsrMirror: 'huggingface', localAsrKeepLoadedSecs: 300, foundryLocalAsrModel: 'whisper-small', + foundryLocalRuntimeSource: 'auto', foundryLocalAsrLanguageHint: '', foundryLocalAsrKeepLoadedSecs: 300, }; diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 24741dd2..63f5b2df 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -58,6 +58,8 @@ export interface LocalAsrDownloadProgress { export interface FoundryLocalAsrStatus { providerId: string; available: boolean; + runtimeReady: boolean; + runtimeSource: FoundryRuntimeSource; activeModel: string; loadedModelId: string | null; endpoint: string | null; @@ -66,6 +68,7 @@ export interface FoundryLocalAsrStatus { export type FoundryLocalAsrModelAlias = 'whisper-small' | 'whisper-base' | 'whisper-tiny'; export type FoundryLocalAsrLanguageHint = '' | 'zh' | 'en'; +export type FoundryRuntimeSource = 'auto' | 'nuget' | 'ort-nightly'; export interface FoundryLocalAsrCatalogModel { alias: FoundryLocalAsrModelAlias; @@ -259,6 +262,8 @@ export function getFoundryLocalAsrStatus(): Promise { return invokeOrMock('foundry_local_asr_status', undefined, () => ({ providerId: 'foundry-local-whisper', available: true, + runtimeReady: false, + runtimeSource: 'auto', activeModel: 'whisper-small', loadedModelId: null, endpoint: null, @@ -282,6 +287,14 @@ export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise { + return invokeOrMock( + 'foundry_local_asr_set_runtime_source', + { source }, + () => undefined, + ); +} + export function prepareFoundryLocalAsr(modelAlias: string): Promise { return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`); } diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 02eb50fd..94a7078e 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -36,6 +36,7 @@ const previousPrefs: UserPreferences = { localAsrMirror: 'huggingface', localAsrKeepLoadedSecs: 300, foundryLocalAsrModel: '', + foundryLocalRuntimeSource: 'auto', foundryLocalAsrLanguageHint: '', foundryLocalAsrKeepLoadedSecs: 300, }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 415b549b..2f1ba652 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -161,6 +161,8 @@ export interface UserPreferences { localAsrKeepLoadedSecs: number; /** Windows Foundry Local Whisper 当前激活的模型 alias。 */ foundryLocalAsrModel: string; + /** Windows Foundry Local native runtime 下载源。 */ + foundryLocalRuntimeSource: string; /** Windows Foundry Local Whisper 语言 hint。空字符串表示自动检测。 */ foundryLocalAsrLanguageHint: string; /** Windows Foundry Local Whisper 模型在 runtime 中保持加载的秒数。 */ diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index b95fc9fd..9d5ace15 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -28,6 +28,7 @@ import { releaseLocalAsrEngine, setFoundryLocalAsrLanguageHint, setFoundryLocalAsrModel, + setFoundryLocalRuntimeSource, setLocalAsrActiveModel, setLocalAsrKeepLoadedSecs, setLocalAsrMirror, @@ -36,6 +37,7 @@ import { type FoundryLocalAsrLanguageHint, type FoundryLocalAsrModelAlias, type FoundryLocalAsrStatus, + type FoundryRuntimeSource, type FoundryPrepareProgress, type LocalAsrDownloadProgress, type LocalAsrEngineStatus, @@ -103,6 +105,8 @@ export function LocalAsr() { setFoundryStatus({ providerId: 'foundry-local-whisper', available: false, + runtimeReady: false, + runtimeSource: selectedFoundryRuntimeSource, activeModel: selectedFoundryAlias, loadedModelId: null, endpoint: null, @@ -306,6 +310,20 @@ export function LocalAsr() { } }; + const handleFoundryRuntimeSourceChange = async (runtimeSource: FoundryRuntimeSource) => { + try { + setError(null); + await setFoundryLocalRuntimeSource(runtimeSource); + await updatePrefs(current => ({ + ...current, + foundryLocalRuntimeSource: runtimeSource, + })); + await refreshFoundryStatus(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + const handleEnableFoundry = async () => { if (!foundryAvailable) return; setFoundryBusy('enable'); @@ -505,7 +523,8 @@ export function LocalAsr() { }; const engineAvailable = settings?.engineAvailable ?? false; - const foundryAvailable = foundryStatus?.available === true; + const foundryPlatformAvailable = isWindowsLikePlatform(); + const foundryAvailable = foundryStatus?.available === true || (foundryPlatformAvailable && foundryStatus?.available !== false); const foundryDefault = prefs?.activeAsrProvider === 'foundry-local-whisper'; const selectedFoundryModel = FOUNDRY_LOCAL_ASR_MODELS.find( model => model.alias === selectedFoundryAlias, @@ -522,6 +541,9 @@ export function LocalAsr() { const selectedFoundryLanguageHint = normalizeFoundryLanguageHintForUi( prefs?.foundryLocalAsrLanguageHint ?? '', ); + const selectedFoundryRuntimeSource = normalizeFoundryRuntimeSourceForUi( + prefs?.foundryLocalRuntimeSource ?? foundryStatus?.runtimeSource ?? 'auto', + ); const foundryPrepareLabel = foundryBusy === 'prepare' ? foundryCancelRequested @@ -561,6 +583,11 @@ export function LocalAsr() { ? t('localAsr.foundryAvailable') : t('localAsr.foundryUnavailable')} + + {foundryStatus?.runtimeReady + ? t('localAsr.foundryRuntimeReady') + : t('localAsr.foundryRuntimeMissing')} +
{t('localAsr.foundryDesc')} @@ -597,6 +624,26 @@ export function LocalAsr() { })} +