[codex] fix Foundry Windows App Runtime prompt (#340)
This commit is contained in:
1 parent
4aa3f6c58d
commit
aa5add1188
25 files changed
+1103
-127
No files matched your search
Generated
+1
@@ -3797,6 +3797,7 @@ dependencies = [
|
||||
"window-vibrancy 0.7.1",
|
||||
"windows 0.58.0",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<String>,
|
||||
pub endpoint: Option<String>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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<i32>,
|
||||
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<PathBuf> {
|
||||
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<F>(source: RuntimeSource, progress: F) -> Result<PathBuf>
|
||||
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<anyhow::Error> = 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<F>(
|
||||
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<CommandCapture> {
|
||||
run_powershell_script(windows_app_runtime_detection_script()).await
|
||||
}
|
||||
|
||||
async fn run_powershell_script(script: &'static str) -> Result<CommandCapture> {
|
||||
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<CommandCapture>) {
|
||||
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<i32>) -> 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::<Vec<_>>().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<String> {
|
||||
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<usize> {
|
||||
let bytes = fs::read(nupkg).with_context(|| format!("read {}", nupkg.display()))?;
|
||||
extract_native_libraries(io::Cursor::new(bytes), out_dir)
|
||||
}
|
||||
|
||||
fn extract_native_libraries<R: Read + io::Seek>(reader: R, out_dir: &Path) -> Result<usize> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub struct FoundryLocalWhisperAsr {
|
||||
#[cfg(target_os = "windows")]
|
||||
runtime: Arc<FoundryLocalRuntime>,
|
||||
model_alias: String,
|
||||
runtime_source: String,
|
||||
language_hint: Option<String>,
|
||||
buffer: Mutex<Vec<u8>>,
|
||||
cancel_generation: AtomicU64,
|
||||
@@ -37,11 +38,13 @@ impl FoundryLocalWhisperAsr {
|
||||
pub fn new(
|
||||
runtime: Arc<FoundryLocalRuntime>,
|
||||
model_alias: String,
|
||||
runtime_source: String,
|
||||
language_hint: Option<String>,
|
||||
) -> 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<String>) -> 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,
|
||||
|
||||
@@ -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<dyn Fn(FoundryPrepareProgressPayload) + Send + Sync + 'static>;
|
||||
@@ -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<String> {
|
||||
self.ensure_loaded_with_progress(alias, |_| {}).await
|
||||
pub async fn ensure_loaded(&self, alias: &str, runtime_source: &str) -> Result<String> {
|
||||
self.ensure_loaded_with_progress(alias, runtime_source, |_| {})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ensure_loaded_with_progress<F>(
|
||||
&self,
|
||||
alias: &str,
|
||||
runtime_source: &str,
|
||||
progress: F,
|
||||
) -> Result<String>
|
||||
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<Vec<FoundryCatalogModel>> {
|
||||
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<String> {
|
||||
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<LoadedModel> {
|
||||
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<LoadedModel> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>) -> Option<PathBuf> {
|
||||
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<String> {
|
||||
pub async fn ensure_loaded(
|
||||
&self,
|
||||
alias: &str,
|
||||
_runtime_source: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}");
|
||||
}
|
||||
|
||||
pub async fn ensure_loaded_with_progress<F>(
|
||||
&self,
|
||||
alias: &str,
|
||||
_runtime_source: &str,
|
||||
_progress: F,
|
||||
) -> anyhow::Result<String>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1582,14 +1582,20 @@ fn normalize_foundry_language_hint(language_hint: &str) -> Result<String, String
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_foundry_runtime_source(source: &str) -> 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<FoundryLocalRuntime>>,
|
||||
) -> FoundryRuntimeStatus {
|
||||
) -> Result<FoundryRuntimeStatus, String> {
|
||||
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<FoundryLocalRuntime>>,
|
||||
model_alias: String,
|
||||
) -> Result<String, String> {
|
||||
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;
|
||||
|
||||
@@ -2044,6 +2044,7 @@ async fn begin_session(inner: &Arc<Inner>) -> 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);
|
||||
|
||||
@@ -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<M: Manager<tauri::Wry>>(
|
||||
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<M: Manager<tauri::Wry>>(
|
||||
.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::<commands::TrayMicrophoneMenuState>();
|
||||
let items = tray_items.lock();
|
||||
let Some(selected) = items.iter().find(|item| item.id == id) else {
|
||||
|
||||
@@ -94,6 +94,43 @@ pub fn local_models_root() -> Result<PathBuf> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR
|
||||
/// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_local_root() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let dir = foundry_local_root()?.join("runtime");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_model_cache_root() -> Result<PathBuf> {
|
||||
let dir = foundry_local_root()?;
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_app_data_root() -> Result<PathBuf> {
|
||||
let dir = foundry_local_root()?.join("app-data");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_logs_root() -> Result<PathBuf> {
|
||||
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() {
|
||||
|
||||
@@ -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<String, LLMError> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '読み込み済みモデル',
|
||||
|
||||
@@ -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: '로드된 모델',
|
||||
|
||||
@@ -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: '已加载模型',
|
||||
|
||||
@@ -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: '已加載模型',
|
||||
|
||||
@@ -70,6 +70,7 @@ const mockSettings: UserPreferences = {
|
||||
localAsrMirror: 'huggingface',
|
||||
localAsrKeepLoadedSecs: 300,
|
||||
foundryLocalAsrModel: 'whisper-small',
|
||||
foundryLocalRuntimeSource: 'auto',
|
||||
foundryLocalAsrLanguageHint: '',
|
||||
foundryLocalAsrKeepLoadedSecs: 300,
|
||||
};
|
||||
|
||||
@@ -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<FoundryLocalAsrStatus> {
|
||||
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<vo
|
||||
);
|
||||
}
|
||||
|
||||
export function setFoundryLocalRuntimeSource(source: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
'foundry_local_asr_set_runtime_source',
|
||||
{ source },
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareFoundryLocalAsr(modelAlias: string): Promise<string> {
|
||||
return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const previousPrefs: UserPreferences = {
|
||||
localAsrMirror: 'huggingface',
|
||||
localAsrKeepLoadedSecs: 300,
|
||||
foundryLocalAsrModel: '',
|
||||
foundryLocalRuntimeSource: 'auto',
|
||||
foundryLocalAsrLanguageHint: '',
|
||||
foundryLocalAsrKeepLoadedSecs: 300,
|
||||
};
|
||||
|
||||
@@ -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 中保持加载的秒数。 */
|
||||
|
||||
@@ -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')}
|
||||
</Pill>
|
||||
<Pill tone={foundryStatus?.runtimeReady ? 'ok' : 'outline'} size="sm">
|
||||
{foundryStatus?.runtimeReady
|
||||
? t('localAsr.foundryRuntimeReady')
|
||||
: t('localAsr.foundryRuntimeMissing')}
|
||||
</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||||
{t('localAsr.foundryDesc')}
|
||||
@@ -597,6 +624,26 @@ export function LocalAsr() {
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.foundryRuntimeSourceLabel')}
|
||||
<select
|
||||
value={selectedFoundryRuntimeSource}
|
||||
onChange={e => void handleFoundryRuntimeSourceChange(e.target.value as FoundryRuntimeSource)}
|
||||
disabled={foundryBusy !== null}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink)',
|
||||
minWidth: 200,
|
||||
}}>
|
||||
<option value="auto">{t('localAsr.foundryRuntimeSourceAuto')}</option>
|
||||
<option value="nuget">{t('localAsr.foundryRuntimeSourceNuget')}</option>
|
||||
<option value="ort-nightly">{t('localAsr.foundryRuntimeSourceOrtNightly')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
{t('localAsr.foundryLanguageLabel')}
|
||||
<select
|
||||
@@ -627,6 +674,11 @@ export function LocalAsr() {
|
||||
<span> · {selectedFoundrySizeLabel} · {selectedFoundryDownloadLabel}</span>
|
||||
<span> · {t(selectedFoundryModel.descKey)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.foundryRuntimeSourceLabel')}: </span>
|
||||
{t(`localAsr.foundryRuntimeSource${selectedFoundryRuntimeSource === 'ort-nightly' ? 'OrtNightly' : selectedFoundryRuntimeSource === 'nuget' ? 'Nuget' : 'Auto'}`)}
|
||||
<span> · {t('localAsr.foundryRuntimeSourceDesc')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.foundryLanguageLabel')}: </span>
|
||||
{selectedFoundryLanguageHint
|
||||
@@ -1109,6 +1161,16 @@ function normalizeFoundryLanguageHintForUi(value: string): FoundryLocalAsrLangua
|
||||
return value === 'zh' || value === 'en' ? value : '';
|
||||
}
|
||||
|
||||
function normalizeFoundryRuntimeSourceForUi(value: string): FoundryRuntimeSource {
|
||||
return value === 'nuget' || value === 'ort-nightly' ? value : 'auto';
|
||||
}
|
||||
|
||||
function isWindowsLikePlatform(): boolean {
|
||||
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
|
||||
const platform = nav.userAgentData?.platform || navigator.platform || navigator.userAgent;
|
||||
return /win/i.test(platform);
|
||||
}
|
||||
|
||||
function formatFoundrySizeMb(fileSizeMb: number | null | undefined): string | null {
|
||||
if (typeof fileSizeMb !== 'number' || fileSizeMb <= 0) return null;
|
||||
return Math.round(fileSizeMb).toLocaleString();
|
||||
|
||||
Reference in new issue
Block a user