[codex] fix Foundry Windows App Runtime prompt (#340)

This commit is contained in:
millionart authored and GitHub committed 2026-05-08 11:42:07 +08:00
1 parent 4aa3f6c58d
commit aa5add1188
25 files changed
+1103 -127

No files matched your search

+1
View File
@@ -3797,6 +3797,7 @@ dependencies = [
"window-vibrancy 0.7.1",
"windows 0.58.0",
"winreg 0.52.0",
"zip 2.4.2",
]
[[package]]
+1
View File
@@ -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;
+3 -1
View File
@@ -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,
};
+23 -4
View File
@@ -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);
+36 -40
View File
@@ -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() {
+27 -33
View File
@@ -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())
}
+15
View File
@@ -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(),
+9 -2
View File
@@ -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',
+9 -2
View File
@@ -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: '読み込み済みモデル',
+9 -2
View File
@@ -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: '로드된 모델',
+9 -2
View File
@@ -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: '已加载模型',
+9 -2
View File
@@ -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: '已加載模型',
+1
View File
@@ -70,6 +70,7 @@ const mockSettings: UserPreferences = {
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
foundryLocalAsrModel: 'whisper-small',
foundryLocalRuntimeSource: 'auto',
foundryLocalAsrLanguageHint: '',
foundryLocalAsrKeepLoadedSecs: 300,
};
+13
View File
@@ -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,
};
+2
View File
@@ -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 中保持加载的秒数。 */
+63 -1
View File
@@ -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();