Merge pull request #565 from H-Chris233/fix/issue-559-local-model-management
Fix local ASR model storage management
This commit is contained in:
13 files changed
+1015
-14
No files matched your search
@@ -114,7 +114,7 @@ mod imp {
|
||||
|
||||
pub async fn catalog_snapshot(&self) -> Result<Vec<FoundryCatalogModel>> {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
if !foundry_native::runtime_ready() {
|
||||
if !foundry_native::runtime_ready() || self.state.lock().manager.is_none() {
|
||||
return Ok(crate::asr::local::foundry::static_catalog_models());
|
||||
}
|
||||
let manager = self.manager()?;
|
||||
@@ -176,6 +176,47 @@ mod imp {
|
||||
self.release_now_locked().await
|
||||
}
|
||||
|
||||
pub fn storage_configuration_locked(&self) -> bool {
|
||||
self.state.lock().manager.is_some()
|
||||
}
|
||||
|
||||
pub async fn model_dir_for_alias(&self, alias: &str) -> Result<PathBuf> {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
if self.state.lock().manager.is_none() {
|
||||
return crate::persistence::foundry_model_cache_root();
|
||||
}
|
||||
let manager = self.manager()?;
|
||||
let model = manager
|
||||
.catalog()
|
||||
.get_model(alias)
|
||||
.await
|
||||
.with_context(|| format!("get Foundry model {alias}"))?;
|
||||
model
|
||||
.path()
|
||||
.await
|
||||
.with_context(|| format!("get Foundry model path {alias}"))
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, alias: &str) -> Result<()> {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
let manager = self.manager()?;
|
||||
let model = manager
|
||||
.catalog()
|
||||
.get_model(alias)
|
||||
.await
|
||||
.with_context(|| format!("get Foundry model {alias}"))?;
|
||||
let loaded = self.cached_loaded_model(alias);
|
||||
if let Some(loaded) = loaded.as_ref() {
|
||||
Self::unload_model(loaded).await?;
|
||||
self.clear_loaded_if_model_id(&loaded.model_id);
|
||||
}
|
||||
model
|
||||
.remove_from_cache()
|
||||
.await
|
||||
.with_context(|| format!("remove Foundry model cache {alias}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_loaded_locked(
|
||||
&self,
|
||||
alias: &str,
|
||||
@@ -653,6 +694,18 @@ impl FoundryLocalRuntime {
|
||||
pub async fn release_now(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn storage_configuration_locked(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn model_dir_for_alias(&self, alias: &str) -> anyhow::Result<std::path::PathBuf> {
|
||||
anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}");
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, alias: &str) -> anyhow::Result<()> {
|
||||
anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -106,6 +106,10 @@ impl SherpaDownloadManager {
|
||||
log::info!("[sherpa-asr] 请求取消模型下载,但没有活跃任务: {model_alias}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self, model_alias: &str) -> bool {
|
||||
self.cancel_flags.lock().contains_key(model_alias)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_info(model_alias: &str, mirror: Mirror) -> Result<SherpaRemoteInfo> {
|
||||
|
||||
@@ -2220,6 +2220,8 @@ pub struct LocalAsrSettings {
|
||||
pub provider_id: String,
|
||||
pub active_model: String,
|
||||
pub mirror: String,
|
||||
pub models_base_dir: Option<String>,
|
||||
pub models_root_dir: String,
|
||||
/// macOS 才编入引擎;Windows 端 UI 需要据此把"开始下载"按钮灰掉。
|
||||
pub engine_available: bool,
|
||||
}
|
||||
@@ -2227,14 +2229,149 @@ pub struct LocalAsrSettings {
|
||||
#[tauri::command]
|
||||
pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
|
||||
let prefs = coord.prefs().get();
|
||||
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
|
||||
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
LocalAsrSettings {
|
||||
provider_id: LOCAL_PROVIDER_ID.into(),
|
||||
active_model: prefs.local_asr_active_model,
|
||||
mirror: prefs.local_asr_mirror,
|
||||
models_base_dir,
|
||||
models_root_dir,
|
||||
engine_available: cfg!(target_os = "macos"),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_string(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalAsrStorageSettings {
|
||||
pub models_base_dir: Option<String>,
|
||||
pub models_root_dir: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_storage_settings(
|
||||
coord: CoordinatorState<'_>,
|
||||
) -> Result<LocalAsrStorageSettings, String> {
|
||||
let prefs = coord.prefs().get();
|
||||
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir);
|
||||
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
|
||||
.map_err(|e| format!("{e:#}"))?
|
||||
.display()
|
||||
.to_string();
|
||||
Ok(LocalAsrStorageSettings {
|
||||
is_default: models_base_dir.is_none(),
|
||||
models_base_dir,
|
||||
models_root_dir,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_asr_set_models_base_dir(
|
||||
coord: CoordinatorState<'_>,
|
||||
qwen_manager: State<'_, Arc<DownloadManager>>,
|
||||
foundry_runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
sherpa_manager: State<'_, Arc<SherpaDownloadManager>>,
|
||||
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
|
||||
models_base_dir: Option<String>,
|
||||
) -> Result<LocalAsrStorageSettings, String> {
|
||||
let next_base_dir = models_base_dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let new_root = crate::persistence::validate_models_base_dir(next_base_dir.as_deref())
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
|
||||
let prefs = coord.prefs().get();
|
||||
let current_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
|
||||
let old_root = crate::persistence::models_root_for_base_dir(current_base_dir.as_deref())
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
let same_root = same_path_for_command(&old_root, &new_root);
|
||||
if same_root && current_base_dir == next_base_dir {
|
||||
return local_asr_storage_settings(coord);
|
||||
}
|
||||
if !same_root && foundry_runtime.storage_configuration_locked() {
|
||||
return Err(
|
||||
"Foundry Local has already been initialized in this app session; restart OpenLess before changing the model storage location"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
quiesce_local_asr_storage_users(
|
||||
coord.inner(),
|
||||
qwen_manager.inner(),
|
||||
foundry_runtime.inner(),
|
||||
sherpa_manager.inner(),
|
||||
sherpa_runtime.inner(),
|
||||
)
|
||||
.await?;
|
||||
crate::persistence::migrate_models_root(&old_root, &new_root).map_err(|e| format!("{e:#}"))?;
|
||||
|
||||
let mut prefs = prefs;
|
||||
prefs.local_asr_models_base_dir = next_base_dir.clone().unwrap_or_default();
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
local_asr_storage_settings(coord)
|
||||
}
|
||||
|
||||
fn same_path_for_command(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
match (left.canonicalize(), right.canonicalize()) {
|
||||
(Ok(left), Ok(right)) => left == right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn quiesce_local_asr_storage_users(
|
||||
coord: &Arc<Coordinator>,
|
||||
qwen_manager: &Arc<DownloadManager>,
|
||||
foundry_runtime: &Arc<FoundryLocalRuntime>,
|
||||
sherpa_manager: &Arc<SherpaDownloadManager>,
|
||||
sherpa_runtime: &Arc<SherpaOnnxRuntime>,
|
||||
) -> Result<(), String> {
|
||||
for model_id in ModelId::all() {
|
||||
qwen_manager.cancel(*model_id);
|
||||
}
|
||||
for model in crate::asr::local::sherpa::MODELS {
|
||||
sherpa_manager.cancel(model.alias);
|
||||
}
|
||||
foundry_runtime.request_cancel_prepare();
|
||||
sherpa_runtime.request_cancel_prepare();
|
||||
coord.release_local_asr_engine();
|
||||
foundry_runtime
|
||||
.release_now()
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
sherpa_runtime
|
||||
.release_now()
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
|
||||
for _ in 0..50 {
|
||||
let qwen_active = ModelId::all()
|
||||
.iter()
|
||||
.any(|model_id| qwen_manager.is_active(*model_id));
|
||||
let sherpa_active = crate::asr::local::sherpa::MODELS
|
||||
.iter()
|
||||
.any(|model| sherpa_manager.is_active(model.alias));
|
||||
if !qwen_active && !sherpa_active {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
Err("local ASR downloads are still stopping; retry after cancellation finishes".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_set_active_model(
|
||||
coord: CoordinatorState<'_>,
|
||||
@@ -2307,6 +2444,31 @@ pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) ->
|
||||
crate::asr::local::models::delete_model(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_model_dir(model_id: String) -> Result<String, String> {
|
||||
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
||||
crate::asr::local::models::model_dir(id)
|
||||
.map(|path| path.display().to_string())
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_reveal_model_dir(model_id: String) -> Result<(), String> {
|
||||
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
||||
let dir = crate::asr::local::models::model_dir(id).map_err(|e| format!("{e:#}"))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("create {} failed: {e}", dir.display()))?;
|
||||
open_path_in_file_manager(&dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_asr_reveal_models_root(coord: CoordinatorState<'_>) -> Result<(), String> {
|
||||
let prefs = coord.prefs().get();
|
||||
let base_dir = non_empty_string(prefs.local_asr_models_base_dir);
|
||||
let dir = crate::persistence::models_root_for_base_dir(base_dir.as_deref())
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
open_path_in_file_manager(&dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_asr_test_model(
|
||||
model_id: String,
|
||||
@@ -2502,6 +2664,44 @@ pub async fn foundry_local_asr_release(
|
||||
runtime.release_now().await.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn foundry_local_asr_model_dir(
|
||||
runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
model_alias: String,
|
||||
) -> Result<String, String> {
|
||||
validate_foundry_model_alias(&model_alias)?;
|
||||
runtime
|
||||
.model_dir_for_alias(&model_alias)
|
||||
.await
|
||||
.map(|path| path.display().to_string())
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn foundry_local_asr_delete_model(
|
||||
runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
model_alias: String,
|
||||
) -> Result<(), String> {
|
||||
validate_foundry_model_alias(&model_alias)?;
|
||||
runtime
|
||||
.delete_model(&model_alias)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn foundry_local_asr_reveal_model_dir(
|
||||
runtime: State<'_, Arc<FoundryLocalRuntime>>,
|
||||
model_alias: String,
|
||||
) -> Result<(), String> {
|
||||
validate_foundry_model_alias(&model_alias)?;
|
||||
let dir = runtime
|
||||
.model_dir_for_alias(&model_alias)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
open_path_in_file_manager(&dir)
|
||||
}
|
||||
|
||||
fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgressPayload) {
|
||||
if let Err(error) = app.emit("foundry-local-asr-prepare-progress", payload) {
|
||||
log::warn!("[foundry-asr] emit prepare progress failed: {error}");
|
||||
@@ -2728,9 +2928,22 @@ fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn open_path_in_file_manager(_path: &std::path::Path) -> Result<(), String> {
|
||||
Err("sherpa-onnx model directory is only supported on Windows".to_string())
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn emit_sherpa_prepare_progress(app: &AppHandle, payload: SherpaPrepareProgressPayload) {
|
||||
|
||||
@@ -410,6 +410,8 @@ pub fn run() {
|
||||
commands::validate_provider_credentials,
|
||||
commands::list_provider_models,
|
||||
commands::local_asr_get_settings,
|
||||
commands::local_asr_storage_settings,
|
||||
commands::local_asr_set_models_base_dir,
|
||||
commands::local_asr_set_active_model,
|
||||
commands::local_asr_set_mirror,
|
||||
commands::local_asr_list_models,
|
||||
@@ -417,6 +419,9 @@ pub fn run() {
|
||||
commands::local_asr_download_model,
|
||||
commands::local_asr_cancel_download,
|
||||
commands::local_asr_delete_model,
|
||||
commands::local_asr_model_dir,
|
||||
commands::local_asr_reveal_model_dir,
|
||||
commands::local_asr_reveal_models_root,
|
||||
commands::local_asr_test_model,
|
||||
commands::local_asr_engine_status,
|
||||
commands::local_asr_release_engine,
|
||||
@@ -430,6 +435,9 @@ pub fn run() {
|
||||
commands::foundry_local_asr_prepare,
|
||||
commands::foundry_local_asr_cancel_prepare,
|
||||
commands::foundry_local_asr_release,
|
||||
commands::foundry_local_asr_model_dir,
|
||||
commands::foundry_local_asr_delete_model,
|
||||
commands::foundry_local_asr_reveal_model_dir,
|
||||
#[cfg(target_os = "windows")]
|
||||
commands::sherpa_onnx_asr_status,
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -130,11 +130,155 @@ fn ensure_dir(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 本地 ASR 模型根目录:`<data_dir>/models/qwen3-asr/`。
|
||||
/// 默认模型根目录:`<data_dir>/models/`。
|
||||
pub fn default_models_root() -> Result<PathBuf> {
|
||||
let dir = data_dir()?.join("models");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 把用户选择的父目录转成实际模型根目录。
|
||||
///
|
||||
/// UI 让用户选一个普通目录;OpenLess 固定在其下创建 `OpenLess/models/`,
|
||||
/// 避免把多个引擎的模型文件直接散落在用户选择目录根部。
|
||||
pub fn models_root_for_base_dir(base_dir: Option<&str>) -> Result<PathBuf> {
|
||||
let trimmed = base_dir.map(str::trim).filter(|value| !value.is_empty());
|
||||
let dir = match trimmed {
|
||||
Some(base) => PathBuf::from(base).join("OpenLess").join("models"),
|
||||
None => return default_models_root(),
|
||||
};
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn configured_models_base_dir() -> Result<Option<String>> {
|
||||
let path = data_dir()?.join(PREFERENCES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.with_context(|| format!("decode failed: {}", path.display()))?;
|
||||
Ok(value
|
||||
.get("localAsrModelsBaseDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
/// 当前配置下的实际模型根目录。
|
||||
pub fn models_root() -> Result<PathBuf> {
|
||||
models_root_for_base_dir(configured_models_base_dir()?.as_deref())
|
||||
}
|
||||
|
||||
/// 校验用户选择的父目录,并返回实际模型根目录。
|
||||
pub fn validate_models_base_dir(base_dir: Option<&str>) -> Result<PathBuf> {
|
||||
let root = models_root_for_base_dir(base_dir)?;
|
||||
let probe = root.join(format!(".openless-write-test-{}", Uuid::new_v4().simple()));
|
||||
fs::write(&probe, b"ok").with_context(|| format!("write probe failed: {}", probe.display()))?;
|
||||
fs::remove_file(&probe).with_context(|| format!("remove probe failed: {}", probe.display()))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// 把旧模型根目录合并迁移到新模型根目录。目标已有内容优先,不覆盖。
|
||||
pub fn migrate_models_root(old_root: &Path, new_root: &Path) -> Result<()> {
|
||||
ensure_dir(new_root)?;
|
||||
if same_existing_path(old_root, new_root) || !old_root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(old_root).with_context(|| format!("read {}", old_root.display()))? {
|
||||
let entry = entry?;
|
||||
merge_move_no_overwrite(&entry.path(), &new_root.join(entry.file_name()))?;
|
||||
}
|
||||
remove_dir_if_empty(old_root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_existing_path(left: &Path, right: &Path) -> bool {
|
||||
match (left.canonicalize(), right.canonicalize()) {
|
||||
(Ok(left), Ok(right)) => left == right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_move_no_overwrite(src: &Path, dest: &Path) -> Result<()> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !dest.exists() {
|
||||
if let Some(parent) = dest.parent() {
|
||||
ensure_dir(parent)?;
|
||||
}
|
||||
return rename_or_copy_remove(src, dest);
|
||||
}
|
||||
if src.is_dir() && dest.is_dir() {
|
||||
for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? {
|
||||
let entry = entry?;
|
||||
merge_move_no_overwrite(&entry.path(), &dest.join(entry.file_name()))?;
|
||||
}
|
||||
remove_dir_if_empty(src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename_or_copy_remove(src: &Path, dest: &Path) -> Result<()> {
|
||||
match fs::rename(src, dest) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) if src.is_dir() => {
|
||||
copy_dir_no_overwrite(src, dest)?;
|
||||
fs::remove_dir_all(src).with_context(|| format!("remove {}", src.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
fs::copy(src, dest)
|
||||
.with_context(|| format!("copy {} to {}", src.display(), dest.display()))?;
|
||||
fs::remove_file(src).with_context(|| format!("remove {}", src.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_no_overwrite(src: &Path, dest: &Path) -> Result<()> {
|
||||
ensure_dir(dest)?;
|
||||
for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
if dest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
if src_path.is_dir() {
|
||||
copy_dir_no_overwrite(&src_path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&src_path, &dest_path).with_context(|| {
|
||||
format!("copy {} to {}", src_path.display(), dest_path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dir_if_empty(path: &Path) -> Result<()> {
|
||||
match fs::read_dir(path) {
|
||||
Ok(entries) => {
|
||||
if entries.count() == 0 {
|
||||
fs::remove_dir(path).with_context(|| format!("remove {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 本地 ASR 模型根目录:`<models_root>/qwen3-asr/`。
|
||||
/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 qwen-asr `download_model.sh`
|
||||
/// 列出的 5–7 个文件。
|
||||
pub fn local_models_root() -> Result<PathBuf> {
|
||||
let dir = data_dir()?.join("models").join("qwen3-asr");
|
||||
let dir = models_root()?.join("qwen3-asr");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -227,7 +371,7 @@ pub fn recording_path_for_session(session_id: &str) -> Result<PathBuf> {
|
||||
/// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_local_root() -> Result<PathBuf> {
|
||||
let dir = data_dir()?.join("models").join("foundry-local");
|
||||
let dir = models_root()?.join("foundry-local");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -241,7 +385,7 @@ pub fn foundry_native_runtime_root() -> Result<PathBuf> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn sherpa_onnx_models_root() -> Result<PathBuf> {
|
||||
let dir = data_dir()?.join("models").join("sherpa-onnx");
|
||||
let dir = models_root()?.join("sherpa-onnx");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -2358,6 +2502,51 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_models_root_uses_openless_models_suffix() {
|
||||
let tmp: PathBuf =
|
||||
std::env::temp_dir().join(format!("openless-model-root-{}", uuid::Uuid::new_v4()));
|
||||
let root = super::models_root_for_base_dir(Some(tmp.to_string_lossy().as_ref()))
|
||||
.expect("build custom models root");
|
||||
|
||||
assert_eq!(root, tmp.join("OpenLess").join("models"));
|
||||
assert!(root.is_dir());
|
||||
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_models_root_merges_without_overwriting_target_files() {
|
||||
let tmp: PathBuf =
|
||||
std::env::temp_dir().join(format!("openless-model-migrate-{}", uuid::Uuid::new_v4()));
|
||||
let old_root = tmp.join("old");
|
||||
let new_root = tmp.join("new");
|
||||
fs::create_dir_all(old_root.join("qwen3-asr")).expect("create old qwen dir");
|
||||
fs::create_dir_all(new_root.join("qwen3-asr")).expect("create new qwen dir");
|
||||
fs::write(old_root.join("qwen3-asr").join("moved.bin"), b"old").expect("write moved");
|
||||
fs::write(old_root.join("qwen3-asr").join("conflict.bin"), b"old")
|
||||
.expect("write old conflict");
|
||||
fs::write(new_root.join("qwen3-asr").join("conflict.bin"), b"new")
|
||||
.expect("write new conflict");
|
||||
|
||||
super::migrate_models_root(&old_root, &new_root).expect("migrate models root");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(new_root.join("qwen3-asr").join("moved.bin")).expect("read moved"),
|
||||
b"old"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(new_root.join("qwen3-asr").join("conflict.bin")).expect("read new conflict"),
|
||||
b"new"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(old_root.join("qwen3-asr").join("conflict.bin")).expect("read old conflict"),
|
||||
b"old"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_rule_syntax_rejects_silent_noops() {
|
||||
assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok());
|
||||
|
||||
@@ -606,6 +606,11 @@ pub struct UserPreferences {
|
||||
/// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
pub local_asr_keep_loaded_secs: u32,
|
||||
/// 本地模型自定义父目录。空字符串 = 使用系统默认 app data 下的 `models/`。
|
||||
/// 非空时,实际模型根目录为 `<local_asr_models_base_dir>/OpenLess/models/`,
|
||||
/// 让用户选择一个普通磁盘目录即可隔离 OpenLess 模型文件。
|
||||
#[serde(default)]
|
||||
pub local_asr_models_base_dir: String,
|
||||
/// Windows Foundry Local Whisper 当前激活的模型 alias。
|
||||
#[serde(default = "default_foundry_local_asr_model")]
|
||||
pub foundry_local_asr_model: String,
|
||||
@@ -793,6 +798,8 @@ struct UserPreferencesWire {
|
||||
local_asr_mirror: String,
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
local_asr_keep_loaded_secs: u32,
|
||||
#[serde(default)]
|
||||
local_asr_models_base_dir: String,
|
||||
#[serde(default = "default_foundry_local_asr_model")]
|
||||
foundry_local_asr_model: String,
|
||||
#[serde(default = "default_foundry_local_runtime_source")]
|
||||
@@ -870,6 +877,7 @@ impl Default for UserPreferencesWire {
|
||||
local_asr_active_model: prefs.local_asr_active_model,
|
||||
local_asr_mirror: prefs.local_asr_mirror,
|
||||
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
||||
local_asr_models_base_dir: prefs.local_asr_models_base_dir,
|
||||
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,
|
||||
@@ -953,6 +961,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
local_asr_active_model: wire.local_asr_active_model,
|
||||
local_asr_mirror: wire.local_asr_mirror,
|
||||
local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs,
|
||||
local_asr_models_base_dir: wire.local_asr_models_base_dir,
|
||||
foundry_local_asr_model: wire.foundry_local_asr_model,
|
||||
foundry_local_runtime_source:
|
||||
crate::asr::local::foundry_native::normalize_runtime_source_str(
|
||||
@@ -1643,6 +1652,7 @@ impl Default for UserPreferences {
|
||||
local_asr_active_model: default_local_asr_model(),
|
||||
local_asr_mirror: default_local_asr_mirror(),
|
||||
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||
local_asr_models_base_dir: String::new(),
|
||||
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(),
|
||||
|
||||
@@ -900,6 +900,20 @@ export const en: typeof zhCN = {
|
||||
kicker: 'LOCAL ASR',
|
||||
title: 'Models',
|
||||
desc: 'Manage on-device speech recognition models.',
|
||||
storageTitle: 'Model storage location',
|
||||
storageBaseDir: 'Selected parent folder',
|
||||
storageModelsRoot: 'Actual models folder',
|
||||
storageDefault: 'System default folder',
|
||||
storageChoose: 'Change folder',
|
||||
storageReset: 'Reset to default',
|
||||
storageReveal: 'Open models folder',
|
||||
storageDesc: 'Custom storage creates OpenLess/models under the selected folder and migrates existing models. OpenLess cancels downloads and releases loaded models before moving files.',
|
||||
storageChooseTitle: 'Choose local model storage parent folder',
|
||||
storageChangeConfirm: 'Existing local models will be moved to {{path}}/OpenLess/models. Downloads will be cancelled and loaded models released first. Continue?',
|
||||
storageResetConfirm: 'Existing local models will be moved back to the system default folder. Current folder: {{path}}. Continue?',
|
||||
modelDir: 'Model directory',
|
||||
revealDir: 'Open directory',
|
||||
deleteConfirm: 'Delete local model files for {{name}}? This cannot be undone.',
|
||||
qwenTitle: 'Qwen3-ASR model manager',
|
||||
qwenExperimentalBadge: 'Experimental',
|
||||
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.',
|
||||
|
||||
@@ -902,6 +902,20 @@ export const ja: typeof zhCN = {
|
||||
kicker: 'ローカル ASR',
|
||||
title: 'モデル設定',
|
||||
desc: 'デバイス上の音声認識モデルを管理。',
|
||||
storageTitle: 'モデル保存場所',
|
||||
storageBaseDir: '選択した親フォルダ',
|
||||
storageModelsRoot: '実際のモデルフォルダ',
|
||||
storageDefault: 'システム既定フォルダ',
|
||||
storageChoose: 'フォルダを変更',
|
||||
storageReset: '既定に戻す',
|
||||
storageReveal: 'モデルフォルダを開く',
|
||||
storageDesc: 'カスタム保存先では選択フォルダ配下に OpenLess/models を作成し、既存モデルを移行します。移行前にダウンロードをキャンセルし、読み込み済みモデルを解放します。',
|
||||
storageChooseTitle: 'ローカルモデル保存先の親フォルダを選択',
|
||||
storageChangeConfirm: '既存のローカルモデルを {{path}}/OpenLess/models に移動します。先にダウンロードをキャンセルし、読み込み済みモデルを解放します。続行しますか?',
|
||||
storageResetConfirm: '既存のローカルモデルをシステム既定フォルダに戻します。現在のフォルダ: {{path}}。続行しますか?',
|
||||
modelDir: 'モデルフォルダ',
|
||||
revealDir: 'フォルダを開く',
|
||||
deleteConfirm: '{{name}} のローカルモデルファイルを削除しますか?この操作は取り消せません。',
|
||||
qwenTitle: 'Qwen3-ASR モデル管理',
|
||||
qwenExperimentalBadge: '実験的',
|
||||
engineUnavailable: '現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。',
|
||||
|
||||
@@ -902,6 +902,20 @@ export const ko: typeof zhCN = {
|
||||
kicker: '로컬 ASR',
|
||||
title: '모델 설정',
|
||||
desc: '기기 내 음성 인식 모델 관리.',
|
||||
storageTitle: '모델 저장 위치',
|
||||
storageBaseDir: '선택한 상위 폴더',
|
||||
storageModelsRoot: '실제 모델 폴더',
|
||||
storageDefault: '시스템 기본 폴더',
|
||||
storageChoose: '폴더 변경',
|
||||
storageReset: '기본값으로 복원',
|
||||
storageReveal: '모델 폴더 열기',
|
||||
storageDesc: '사용자 지정 저장소는 선택한 폴더 아래에 OpenLess/models 를 만들고 기존 모델을 이동합니다. 이동 전에 다운로드를 취소하고 로드된 모델을 해제합니다.',
|
||||
storageChooseTitle: '로컬 모델 저장 상위 폴더 선택',
|
||||
storageChangeConfirm: '기존 로컬 모델을 {{path}}/OpenLess/models 로 이동합니다. 먼저 다운로드를 취소하고 로드된 모델을 해제합니다. 계속할까요?',
|
||||
storageResetConfirm: '기존 로컬 모델을 시스템 기본 폴더로 되돌립니다. 현재 폴더: {{path}}. 계속할까요?',
|
||||
modelDir: '모델 폴더',
|
||||
revealDir: '폴더 열기',
|
||||
deleteConfirm: '{{name}} 로컬 모델 파일을 삭제할까요? 되돌릴 수 없습니다.',
|
||||
qwenTitle: 'Qwen3-ASR 모델 관리',
|
||||
qwenExperimentalBadge: '실험적',
|
||||
engineUnavailable: '현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.',
|
||||
|
||||
@@ -898,6 +898,20 @@ export const zhCN = {
|
||||
kicker: '本地 ASR',
|
||||
title: '模型设置',
|
||||
desc: '管理本机语音识别模型。',
|
||||
storageTitle: '模型存储位置',
|
||||
storageBaseDir: '选择的父目录',
|
||||
storageModelsRoot: '实际模型目录',
|
||||
storageDefault: '系统默认目录',
|
||||
storageChoose: '更改目录',
|
||||
storageReset: '恢复默认',
|
||||
storageReveal: '打开模型总目录',
|
||||
storageDesc: '自定义目录会在所选位置下创建 OpenLess/models,并自动迁移现有模型;迁移前会取消下载和释放已加载模型。',
|
||||
storageChooseTitle: '选择本地模型存储父目录',
|
||||
storageChangeConfirm: '将把现有本地模型迁移到 {{path}}/OpenLess/models。迁移前会自动取消下载并释放已加载模型。是否继续?',
|
||||
storageResetConfirm: '将把现有本地模型迁回系统默认目录。当前目录:{{path}}。是否继续?',
|
||||
modelDir: '模型目录',
|
||||
revealDir: '打开目录',
|
||||
deleteConfirm: '确定删除 {{name}} 的本地模型文件吗?此操作无法撤销。',
|
||||
qwenTitle: 'Qwen3-ASR 模型管理',
|
||||
qwenExperimentalBadge: '实验性',
|
||||
engineUnavailable: '当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。',
|
||||
|
||||
@@ -900,6 +900,20 @@ export const zhTW: typeof zhCN = {
|
||||
kicker: '本地 ASR',
|
||||
title: '模型設置',
|
||||
desc: '管理本機語音識別模型。',
|
||||
storageTitle: '模型儲存位置',
|
||||
storageBaseDir: '選擇的父目錄',
|
||||
storageModelsRoot: '實際模型目錄',
|
||||
storageDefault: '系統預設目錄',
|
||||
storageChoose: '更改目錄',
|
||||
storageReset: '恢復預設',
|
||||
storageReveal: '開啟模型總目錄',
|
||||
storageDesc: '自訂目錄會在所選位置下建立 OpenLess/models,並自動遷移現有模型;遷移前會取消下載和釋放已載入模型。',
|
||||
storageChooseTitle: '選擇本地模型儲存父目錄',
|
||||
storageChangeConfirm: '將把現有本地模型遷移到 {{path}}/OpenLess/models。遷移前會自動取消下載並釋放已載入模型。是否繼續?',
|
||||
storageResetConfirm: '將把現有本地模型遷回系統預設目錄。當前目錄:{{path}}。是否繼續?',
|
||||
modelDir: '模型目錄',
|
||||
revealDir: '開啟目錄',
|
||||
deleteConfirm: '確定刪除 {{name}} 的本地模型檔案嗎?此操作無法復原。',
|
||||
qwenTitle: 'Qwen3-ASR 模型管理',
|
||||
qwenExperimentalBadge: '實驗性',
|
||||
engineUnavailable: '當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。',
|
||||
|
||||
@@ -14,10 +14,18 @@ export interface LocalAsrSettings {
|
||||
providerId: string
|
||||
activeModel: string
|
||||
mirror: string
|
||||
modelsBaseDir: string | null
|
||||
modelsRootDir: string
|
||||
/** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */
|
||||
engineAvailable: boolean
|
||||
}
|
||||
|
||||
export interface LocalAsrStorageSettings {
|
||||
modelsBaseDir: string | null
|
||||
modelsRootDir: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface LocalAsrModelStatus {
|
||||
id: string
|
||||
hfRepo: string
|
||||
@@ -172,6 +180,8 @@ const MOCK_SETTINGS: LocalAsrSettings = {
|
||||
providerId: "local-qwen3",
|
||||
activeModel: "qwen3-asr-0.6b",
|
||||
mirror: "huggingface",
|
||||
modelsBaseDir: null,
|
||||
modelsRootDir: "~/Library/Application Support/OpenLess/models",
|
||||
engineAvailable: false,
|
||||
}
|
||||
|
||||
@@ -198,6 +208,30 @@ export function getLocalAsrSettings(): Promise<LocalAsrSettings> {
|
||||
)
|
||||
}
|
||||
|
||||
export function getLocalAsrStorageSettings(): Promise<LocalAsrStorageSettings> {
|
||||
return invokeOrMock("local_asr_storage_settings", undefined, () => ({
|
||||
modelsBaseDir: null,
|
||||
modelsRootDir: MOCK_SETTINGS.modelsRootDir,
|
||||
isDefault: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function setLocalAsrModelsBaseDir(
|
||||
modelsBaseDir: string | null,
|
||||
): Promise<LocalAsrStorageSettings> {
|
||||
return invokeOrMock(
|
||||
"local_asr_set_models_base_dir",
|
||||
{ modelsBaseDir },
|
||||
() => ({
|
||||
modelsBaseDir,
|
||||
modelsRootDir: modelsBaseDir
|
||||
? `${modelsBaseDir}/OpenLess/models`
|
||||
: MOCK_SETTINGS.modelsRootDir,
|
||||
isDefault: !modelsBaseDir,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function setLocalAsrActiveModel(modelId: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"local_asr_set_active_model",
|
||||
@@ -253,6 +287,26 @@ export function deleteLocalAsrModel(modelId: string): Promise<void> {
|
||||
return invokeOrMock("local_asr_delete_model", { modelId }, () => undefined)
|
||||
}
|
||||
|
||||
export function getLocalAsrModelDir(modelId: string): Promise<string> {
|
||||
return invokeOrMock("local_asr_model_dir", { modelId }, () => "")
|
||||
}
|
||||
|
||||
export function revealLocalAsrModelDir(modelId: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"local_asr_reveal_model_dir",
|
||||
{ modelId },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function revealLocalAsrModelsRoot(): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"local_asr_reveal_models_root",
|
||||
undefined,
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export interface LocalAsrTestResult {
|
||||
backend: string
|
||||
modelId: string
|
||||
@@ -377,6 +431,32 @@ export function releaseFoundryLocalAsr(): Promise<void> {
|
||||
return invokeOrMock("foundry_local_asr_release", undefined, () => undefined)
|
||||
}
|
||||
|
||||
export function getFoundryLocalAsrModelDir(modelAlias: string): Promise<string> {
|
||||
return invokeOrMock(
|
||||
"foundry_local_asr_model_dir",
|
||||
{ modelAlias },
|
||||
() => "",
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteFoundryLocalAsrModel(modelAlias: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"foundry_local_asr_delete_model",
|
||||
{ modelAlias },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function revealFoundryLocalAsrModelDir(
|
||||
modelAlias: string,
|
||||
): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"foundry_local_asr_reveal_model_dir",
|
||||
{ modelAlias },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sherpa-Onnx Local ASR ───────────────────────────────────────────
|
||||
|
||||
export type SherpaOnnxModelAlias =
|
||||
|
||||
@@ -24,15 +24,18 @@ import {
|
||||
cancelSherpaOnnxAsrDownload,
|
||||
cancelSherpaOnnxAsrPrepare,
|
||||
cancelLocalAsrDownload,
|
||||
deleteFoundryLocalAsrModel,
|
||||
deleteSherpaOnnxAsrModel,
|
||||
deleteLocalAsrModel,
|
||||
downloadLocalAsrModel,
|
||||
downloadSherpaOnnxAsrModel,
|
||||
fetchLocalAsrRemoteInfo,
|
||||
fetchSherpaOnnxAsrRemoteInfo,
|
||||
getFoundryLocalAsrModelDir,
|
||||
getFoundryLocalAsrCatalog,
|
||||
getFoundryLocalAsrStatus,
|
||||
getLocalAsrEngineStatus,
|
||||
getLocalAsrModelDir,
|
||||
getLocalAsrSettings,
|
||||
getSherpaOnnxAsrCatalog,
|
||||
getSherpaOnnxAsrModelDir,
|
||||
@@ -44,7 +47,11 @@ import {
|
||||
releaseFoundryLocalAsr,
|
||||
releaseLocalAsrEngine,
|
||||
releaseSherpaOnnxAsr,
|
||||
revealFoundryLocalAsrModelDir,
|
||||
revealLocalAsrModelDir,
|
||||
revealLocalAsrModelsRoot,
|
||||
revealSherpaOnnxAsrModelDir,
|
||||
setLocalAsrModelsBaseDir,
|
||||
setFoundryLocalAsrLanguageHint,
|
||||
setFoundryLocalAsrModel,
|
||||
setFoundryLocalRuntimeSource,
|
||||
@@ -108,6 +115,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
const { prefs, updatePrefs } = useHotkeySettings()
|
||||
const [settings, setSettings] = useState<LocalAsrSettings | null>(null)
|
||||
const [models, setModels] = useState<LocalAsrModelStatus[]>([])
|
||||
const [modelDirs, setModelDirs] = useState<Record<string, string>>({})
|
||||
const [progress, setProgress] = useState<
|
||||
Record<string, LocalAsrDownloadProgress>
|
||||
>({})
|
||||
@@ -116,6 +124,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyModelId, setBusyModelId] = useState<string | null>(null)
|
||||
const [storageBusy, setStorageBusy] = useState(false)
|
||||
const [foundryStatus, setFoundryStatus] =
|
||||
useState<FoundryLocalAsrStatus | null>(null)
|
||||
const [foundryCatalog, setFoundryCatalog] = useState<
|
||||
@@ -124,11 +133,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
const [selectedFoundryAlias, setSelectedFoundryAlias] =
|
||||
useState<FoundryLocalAsrModelAlias>("whisper-small")
|
||||
const [foundryBusy, setFoundryBusy] = useState<
|
||||
"enable" | "prepare" | "release" | null
|
||||
"enable" | "prepare" | "release" | "delete" | "reveal" | null
|
||||
>(null)
|
||||
const [foundryProgress, setFoundryProgress] =
|
||||
useState<FoundryPrepareProgress | null>(null)
|
||||
const [foundryCancelRequested, setFoundryCancelRequested] = useState(false)
|
||||
const [foundryModelDir, setFoundryModelDir] = useState<{
|
||||
alias: FoundryLocalAsrModelAlias
|
||||
dir: string
|
||||
} | null>(null)
|
||||
const [sherpaStatus, setSherpaStatus] =
|
||||
useState<SherpaOnnxAsrStatus | null>(null)
|
||||
const [sherpaCatalog, setSherpaCatalog] = useState<
|
||||
@@ -169,6 +182,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
const sherpaDownloadRefreshTimer = useRef<number | null>(null)
|
||||
const engineStatusTimer = useRef<number | null>(null)
|
||||
const foundrySelectionDirty = useRef(false)
|
||||
const selectedFoundryAliasRef =
|
||||
useRef<FoundryLocalAsrModelAlias>("whisper-small")
|
||||
const sherpaSelectionDirty = useRef(false)
|
||||
const sherpaAnchorRef = useRef<HTMLDivElement>(null)
|
||||
const scrollGuard = useRef<{ scroller: HTMLElement; top: number } | null>(
|
||||
@@ -242,6 +257,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentFoundryAlias = (alias: FoundryLocalAsrModelAlias) => {
|
||||
if (selectedFoundryAliasRef.current !== alias) {
|
||||
setFoundryModelDir(null)
|
||||
}
|
||||
selectedFoundryAliasRef.current = alias
|
||||
setSelectedFoundryAlias(alias)
|
||||
}
|
||||
|
||||
const refreshEngineStatus = async () => {
|
||||
try {
|
||||
const status = await getLocalAsrEngineStatus()
|
||||
@@ -259,7 +282,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
!foundrySelectionDirty.current &&
|
||||
isFoundryAlias(status.activeModel)
|
||||
) {
|
||||
setSelectedFoundryAlias(status.activeModel)
|
||||
setCurrentFoundryAlias(status.activeModel)
|
||||
void refreshFoundryModelDir(status.activeModel)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -285,6 +309,34 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshFoundryModelDir = async (
|
||||
modelAlias: FoundryLocalAsrModelAlias,
|
||||
) => {
|
||||
try {
|
||||
const dir = await getFoundryLocalAsrModelDir(modelAlias)
|
||||
setFoundryModelDir((current) => {
|
||||
if (selectedFoundryAliasRef.current !== modelAlias) {
|
||||
return current
|
||||
}
|
||||
if (current?.alias === modelAlias && current.dir === dir) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
alias: modelAlias,
|
||||
dir,
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn("[localAsr] Foundry model dir query failed", err)
|
||||
setFoundryModelDir((current) =>
|
||||
selectedFoundryAliasRef.current === modelAlias &&
|
||||
current?.alias === modelAlias
|
||||
? null
|
||||
: current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSherpaStatus = async () => {
|
||||
try {
|
||||
const status = await getSherpaOnnxAsrStatus()
|
||||
@@ -336,10 +388,25 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
])
|
||||
setSettings(s)
|
||||
setModels(list)
|
||||
void Promise.all(
|
||||
list.map(async (m) => {
|
||||
try {
|
||||
const dir = await getLocalAsrModelDir(m.id)
|
||||
setModelDirs((current) =>
|
||||
current[m.id] === dir
|
||||
? current
|
||||
: { ...current, [m.id]: dir },
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn("[localAsr] Qwen3 model dir query failed", err)
|
||||
}
|
||||
}),
|
||||
)
|
||||
void refreshEngineStatus()
|
||||
if (IS_WINDOWS) {
|
||||
void refreshFoundryStatus()
|
||||
void refreshFoundryCatalog()
|
||||
void refreshFoundryModelDir(selectedFoundryAlias)
|
||||
void refreshSherpaStatus()
|
||||
void refreshSherpaCatalog()
|
||||
void refreshSherpaModelDir(selectedSherpaAlias)
|
||||
@@ -674,6 +741,76 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const applyModelsBaseDir = async (modelsBaseDir: string | null) => {
|
||||
setStorageBusy(true)
|
||||
try {
|
||||
setError(null)
|
||||
const next = await setLocalAsrModelsBaseDir(modelsBaseDir)
|
||||
setSettings((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
modelsBaseDir: next.modelsBaseDir,
|
||||
modelsRootDir: next.modelsRootDir,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
await refresh()
|
||||
void refreshFoundryModelDir(selectedFoundryAlias)
|
||||
void refreshSherpaModelDir(selectedSherpaAlias)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setStorageBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChooseModelsBaseDir = async () => {
|
||||
if (!isTauri) {
|
||||
await applyModelsBaseDir("~/OpenLessModels")
|
||||
return
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog")
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t("localAsr.storageChooseTitle"),
|
||||
})
|
||||
if (!picked || Array.isArray(picked)) return
|
||||
if (
|
||||
!window.confirm(
|
||||
t("localAsr.storageChangeConfirm", {
|
||||
path: picked,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
await applyModelsBaseDir(picked)
|
||||
}
|
||||
|
||||
const handleResetModelsBaseDir = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("localAsr.storageResetConfirm", {
|
||||
path: settings?.modelsRootDir ?? "",
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
await applyModelsBaseDir(null)
|
||||
}
|
||||
|
||||
const handleRevealModelsRoot = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await revealLocalAsrModelsRoot()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const syncFoundryPrefs = async (
|
||||
modelAlias: FoundryLocalAsrModelAlias,
|
||||
enableProvider: boolean,
|
||||
@@ -810,6 +947,43 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevealFoundryDir = async () => {
|
||||
setFoundryBusy("reveal")
|
||||
try {
|
||||
setError(null)
|
||||
await revealFoundryLocalAsrModelDir(selectedFoundryAlias)
|
||||
await refreshFoundryModelDir(selectedFoundryAlias)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setFoundryBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteFoundry = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("localAsr.deleteConfirm", {
|
||||
name: selectedFoundryDisplayName,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setFoundryBusy("delete")
|
||||
try {
|
||||
setError(null)
|
||||
await deleteFoundryLocalAsrModel(selectedFoundryAlias)
|
||||
await refreshFoundryStatus()
|
||||
await refreshFoundryCatalog()
|
||||
await refreshFoundryModelDir(selectedFoundryAlias)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setFoundryBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const syncSherpaPrefs = async (
|
||||
modelAlias: SherpaOnnxModelAlias,
|
||||
enableProvider: boolean,
|
||||
@@ -954,6 +1128,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
|
||||
const handleDeleteSherpa = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("localAsr.deleteConfirm", {
|
||||
name: selectedSherpaDisplayName,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSherpaBusy("delete")
|
||||
try {
|
||||
setError(null)
|
||||
@@ -1104,6 +1287,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
|
||||
const handleDelete = async (modelId: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("localAsr.deleteConfirm", {
|
||||
name: modelId,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setBusyModelId(modelId)
|
||||
try {
|
||||
await deleteLocalAsrModel(modelId)
|
||||
@@ -1120,6 +1312,20 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevealModelDir = async (modelId: string) => {
|
||||
setBusyModelId(modelId)
|
||||
try {
|
||||
setError(null)
|
||||
await revealLocalAsrModelDir(modelId)
|
||||
const dir = await getLocalAsrModelDir(modelId)
|
||||
setModelDirs((current) => ({ ...current, [modelId]: dir }))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyModelId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeepLoadedChange = async (seconds: number) => {
|
||||
try {
|
||||
await setLocalAsrKeepLoadedSecs(seconds)
|
||||
@@ -1400,6 +1606,110 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: "1 1 360px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "var(--ol-ink)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{t("localAsr.storageTitle")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
color: "var(--ol-ink-3)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{ color: "var(--ol-ink-4)" }}
|
||||
>
|
||||
{t("localAsr.storageBaseDir")}:{" "}
|
||||
</span>
|
||||
<code>
|
||||
{settings?.modelsBaseDir ??
|
||||
t("localAsr.storageDefault")}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
style={{ color: "var(--ol-ink-4)" }}
|
||||
>
|
||||
{t("localAsr.storageModelsRoot")}:{" "}
|
||||
</span>
|
||||
<code>{settings?.modelsRootDir ?? "—"}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
alignContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Btn
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={storageBusy}
|
||||
onClick={() => void handleChooseModelsBaseDir()}
|
||||
>
|
||||
{storageBusy
|
||||
? t("common.loading")
|
||||
: t("localAsr.storageChoose")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={storageBusy || !settings?.modelsBaseDir}
|
||||
onClick={() => void handleResetModelsBaseDir()}
|
||||
>
|
||||
{t("localAsr.storageReset")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={storageBusy}
|
||||
onClick={() => void handleRevealModelsRoot()}
|
||||
>
|
||||
{t("localAsr.storageReveal")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--ol-ink-4)",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{t("localAsr.storageDesc")}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{IS_WINDOWS && (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
@@ -1503,11 +1813,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
preserveEmbeddedScroll(
|
||||
e.currentTarget,
|
||||
)
|
||||
const nextAlias = e.target
|
||||
.value as FoundryLocalAsrModelAlias
|
||||
foundrySelectionDirty.current = true
|
||||
setSelectedFoundryAlias(
|
||||
e.target
|
||||
.value as FoundryLocalAsrModelAlias,
|
||||
)
|
||||
setCurrentFoundryAlias(nextAlias)
|
||||
void refreshFoundryModelDir(nextAlias)
|
||||
restoreScroll()
|
||||
}}
|
||||
disabled={foundryBusy !== null}
|
||||
@@ -1702,6 +2012,17 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
</span>
|
||||
{foundryStatus?.activeModel ?? "whisper-small"}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: "var(--ol-ink-4)" }}>
|
||||
{t("localAsr.modelDir")}:{" "}
|
||||
</span>
|
||||
<code>
|
||||
{foundryModelDir?.alias ===
|
||||
selectedFoundryAlias
|
||||
? foundryModelDir.dir
|
||||
: "—"}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: "var(--ol-ink-4)" }}>
|
||||
{t("localAsr.foundryLoadedModel")}:{" "}
|
||||
@@ -1783,6 +2104,26 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
? t("localAsr.foundryReleasing")
|
||||
: t("localAsr.releaseNow")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={foundryBusy !== null}
|
||||
onClick={() => void handleRevealFoundryDir()}
|
||||
>
|
||||
{foundryBusy === "reveal"
|
||||
? t("common.loading")
|
||||
: t("localAsr.revealDir")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={foundryBusy !== null}
|
||||
onClick={() => void handleDeleteFoundry()}
|
||||
>
|
||||
{foundryBusy === "delete"
|
||||
? t("common.loading")
|
||||
: t("localAsr.delete")}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -2437,6 +2778,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
modelDir={modelDirs[model.id] ?? ""}
|
||||
remoteSize={remoteSizes[model.id]}
|
||||
progress={progress[model.id]}
|
||||
isActive={settings?.activeModel === model.id}
|
||||
@@ -2449,6 +2791,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
onDownload={() => void handleDownload(model.id)}
|
||||
onCancel={() => void handleCancel(model.id)}
|
||||
onDelete={() => void handleDelete(model.id)}
|
||||
onReveal={() => void handleRevealModelDir(model.id)}
|
||||
onSetActive={() =>
|
||||
void handleSetActiveModel(model.id)
|
||||
}
|
||||
@@ -2690,6 +3033,7 @@ function DownloadProgressBlock({
|
||||
|
||||
interface ModelRowProps {
|
||||
model: LocalAsrModelStatus
|
||||
modelDir: string
|
||||
remoteSize?: RemoteSize
|
||||
progress?: LocalAsrDownloadProgress
|
||||
isActive: boolean
|
||||
@@ -2700,12 +3044,14 @@ interface ModelRowProps {
|
||||
onDownload: () => void
|
||||
onCancel: () => void
|
||||
onDelete: () => void
|
||||
onReveal: () => void
|
||||
onSetActive: () => void
|
||||
onTest: () => void
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
model,
|
||||
modelDir,
|
||||
remoteSize,
|
||||
progress,
|
||||
isActive,
|
||||
@@ -2716,6 +3062,7 @@ function ModelRow({
|
||||
onDownload,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onReveal,
|
||||
onSetActive,
|
||||
onTest,
|
||||
}: ModelRowProps) {
|
||||
@@ -2783,6 +3130,17 @@ function ModelRow({
|
||||
<div style={{ fontSize: 12, color: "var(--ol-ink-3)" }}>
|
||||
{model.hfRepo} · {sizeLabel}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--ol-ink-4)",
|
||||
marginTop: 4,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{t("localAsr.modelDir")}:{" "}
|
||||
<code>{modelDir || "—"}</code>
|
||||
</div>
|
||||
{showProgress && (
|
||||
<div style={{ marginTop: 10, maxWidth: 420 }}>
|
||||
<div
|
||||
@@ -2864,6 +3222,14 @@ function ModelRow({
|
||||
>
|
||||
{t("localAsr.delete")}
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onReveal}
|
||||
>
|
||||
{t("localAsr.revealDir")}
|
||||
</Btn>
|
||||
</>
|
||||
) : isDownloading ? (
|
||||
<Btn variant="ghost" size="sm" onClick={onCancel}>
|
||||
@@ -2891,6 +3257,14 @@ function ModelRow({
|
||||
{t("localAsr.delete")}
|
||||
</Btn>
|
||||
)}
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onReveal}
|
||||
>
|
||||
{t("localAsr.revealDir")}
|
||||
</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user