Merge pull request #523 from weikeyi/fix/sherpa-local-asr-state-sync-v2
Fix Sherpa local ASR state sync
This commit is contained in:
9 files changed
+462
-52
No files matched your search
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! 推理接入见 `sherpa_runtime.rs`(M2)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
@@ -128,6 +128,25 @@ pub fn required_files_for_alias(alias: &str) -> Result<&'static [&'static str]>
|
||||
}
|
||||
}
|
||||
|
||||
pub fn required_path_is_valid(alias: &str, required: &str, path: &Path) -> bool {
|
||||
if required_path_is_dir(alias, required) {
|
||||
required_dir_is_valid(alias, required, path)
|
||||
} else {
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
fn required_path_is_dir(alias: &str, required: &str) -> bool {
|
||||
matches!((alias, required), ("qwen3-asr-0.6b-int8", "tokenizer"))
|
||||
}
|
||||
|
||||
fn required_dir_is_valid(alias: &str, required: &str, path: &Path) -> bool {
|
||||
match (alias, required) {
|
||||
("qwen3-asr-0.6b-int8", "tokenizer") => path.join("tokenizer.json").is_file(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn download_files_for_alias(alias: &str) -> Result<&'static [(&'static str, &'static str)]> {
|
||||
match alias {
|
||||
"sense-voice-small-zh" => Ok(&[
|
||||
@@ -184,6 +203,7 @@ pub struct SherpaCatalogModel {
|
||||
pub mode: SherpaMode,
|
||||
pub languages: Vec<String>,
|
||||
pub cached: bool,
|
||||
pub downloaded_bytes: u64,
|
||||
pub file_size_mb: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -197,6 +217,7 @@ impl SherpaCatalogModel {
|
||||
mode: model.mode,
|
||||
languages: model.languages.iter().map(|s| s.to_string()).collect(),
|
||||
cached: false,
|
||||
downloaded_bytes: 0,
|
||||
file_size_mb: None,
|
||||
}
|
||||
}
|
||||
@@ -294,6 +315,7 @@ impl SherpaRuntimeStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn provider_id_is_stable() {
|
||||
@@ -322,6 +344,30 @@ mod tests {
|
||||
assert!(catalog.iter().all(|m| !m.cached));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen3_tokenizer_requires_tokenizer_json() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openless-sherpa-tokenizer-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("create tokenizer dir");
|
||||
|
||||
assert!(!required_path_is_valid(
|
||||
"qwen3-asr-0.6b-int8",
|
||||
"tokenizer",
|
||||
&dir
|
||||
));
|
||||
|
||||
fs::write(dir.join("tokenizer.json"), b"{}").expect("write tokenizer json");
|
||||
assert!(required_path_is_valid(
|
||||
"qwen3-asr-0.6b-int8",
|
||||
"tokenizer",
|
||||
&dir
|
||||
));
|
||||
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_status_uses_provider_id() {
|
||||
let status = SherpaRuntimeStatus::unavailable("paraformer-zh".into(), "not ready");
|
||||
|
||||
@@ -255,23 +255,40 @@ fn downloaded_release_archive_bytes(
|
||||
archive: sherpa::SherpaReleaseArchive,
|
||||
) -> u64 {
|
||||
let dest = dir.join(archive.file_name);
|
||||
let (extracted, extracted_complete) = extracted_release_archive_bytes(dir, model_alias);
|
||||
if extracted_complete {
|
||||
return extracted;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(&dest) {
|
||||
return meta.len();
|
||||
}
|
||||
let partial = partial_actual_size(&dest.with_extension("partial"));
|
||||
if partial > 0 {
|
||||
return partial;
|
||||
}
|
||||
partial.max(extracted)
|
||||
}
|
||||
|
||||
fn finished_release_archive_progress_bytes(
|
||||
dir: &Path,
|
||||
model_alias: &str,
|
||||
archive: sherpa::SherpaReleaseArchive,
|
||||
) -> (u64, u64) {
|
||||
let finished_bytes = downloaded_release_archive_bytes(dir, model_alias, archive);
|
||||
(finished_bytes, finished_bytes)
|
||||
}
|
||||
|
||||
fn extracted_release_archive_bytes(dir: &Path, model_alias: &str) -> (u64, bool) {
|
||||
if let Ok(files) = sherpa::required_files_for_alias(model_alias) {
|
||||
let total: u64 = files
|
||||
.iter()
|
||||
.map(|f| path_size_recursive(&dir.join(f)))
|
||||
.sum();
|
||||
if total > 0 {
|
||||
return total;
|
||||
let mut total = 0;
|
||||
let mut complete = true;
|
||||
for file in files {
|
||||
let path = dir.join(file);
|
||||
total += path_size_recursive(&path);
|
||||
if !sherpa::required_path_is_valid(model_alias, file, &path) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return (total, complete);
|
||||
}
|
||||
0
|
||||
(0, false)
|
||||
}
|
||||
|
||||
fn path_size_recursive(path: &Path) -> u64 {
|
||||
@@ -546,15 +563,26 @@ async fn run_release_archive_download(
|
||||
},
|
||||
);
|
||||
});
|
||||
let result = download_one(
|
||||
&client,
|
||||
archive.url,
|
||||
&archive_path,
|
||||
total_bytes,
|
||||
Arc::clone(&cancel),
|
||||
on_progress,
|
||||
)
|
||||
.await;
|
||||
let archive_file = info
|
||||
.files
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("release archive file info missing"))?;
|
||||
let result = if archive_file_is_verified(&archive_path, archive_file) {
|
||||
Ok(())
|
||||
} else {
|
||||
if archive_path.exists() {
|
||||
remove_path_if_exists(&archive_path)?;
|
||||
}
|
||||
download_one(
|
||||
&client,
|
||||
archive.url,
|
||||
&archive_path,
|
||||
total_bytes,
|
||||
Arc::clone(&cancel),
|
||||
on_progress,
|
||||
)
|
||||
.await
|
||||
};
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
emit_cancelled(app, model_alias, file_count, total_bytes);
|
||||
return Ok(());
|
||||
@@ -563,10 +591,14 @@ async fn run_release_archive_download(
|
||||
emit_failed(app, model_alias, file_count, total_bytes, &error);
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = verify_file(&archive_path, archive_file) {
|
||||
emit_failed(app, model_alias, file_count, total_bytes, &error);
|
||||
return Err(error);
|
||||
}
|
||||
let archive_path_for_extract = archive_path.clone();
|
||||
let dir_for_extract = dir.to_path_buf();
|
||||
let model_alias_for_extract = model_alias.to_string();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let extract_result = tauri::async_runtime::spawn_blocking(move || {
|
||||
extract_release_archive(
|
||||
&archive_path_for_extract,
|
||||
&dir_for_extract,
|
||||
@@ -575,7 +607,14 @@ async fn run_release_archive_download(
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("extract join failed: {error:#}"))??;
|
||||
.map_err(|error| anyhow::anyhow!("extract join failed: {error:#}"))
|
||||
.and_then(|result| result);
|
||||
if let Err(error) = extract_result {
|
||||
emit_failed(app, model_alias, file_count, total_bytes, &error);
|
||||
return Err(error);
|
||||
}
|
||||
let (finished_bytes, finished_total_bytes) =
|
||||
finished_release_archive_progress_bytes(dir, model_alias, archive);
|
||||
emit(
|
||||
app,
|
||||
DownloadProgress {
|
||||
@@ -583,8 +622,8 @@ async fn run_release_archive_download(
|
||||
file: String::new(),
|
||||
file_index: file_count,
|
||||
file_count,
|
||||
bytes_downloaded: total_bytes,
|
||||
bytes_total: total_bytes,
|
||||
bytes_downloaded: finished_bytes,
|
||||
bytes_total: finished_total_bytes,
|
||||
phase: DownloadPhase::Finished,
|
||||
error: None,
|
||||
},
|
||||
@@ -612,7 +651,14 @@ fn extract_release_archive(
|
||||
if !root.exists() {
|
||||
anyhow::bail!("archive root missing: {}", root.display());
|
||||
}
|
||||
for required in sherpa::required_files_for_alias(model_alias)? {
|
||||
let required_files = sherpa::required_files_for_alias(model_alias)?;
|
||||
for required in required_files {
|
||||
let src = root.join(required);
|
||||
if !sherpa::required_path_is_valid(model_alias, required, &src) {
|
||||
anyhow::bail!("archive required path missing: {}", src.display());
|
||||
}
|
||||
}
|
||||
for required in required_files {
|
||||
let src = root.join(required);
|
||||
let dest = dir.join(required);
|
||||
move_path(&src, &dest)?;
|
||||
@@ -698,6 +744,10 @@ fn file_is_verified(path: &Path, file: &SherpaRemoteFile) -> bool {
|
||||
path.exists() && verify_file(path, file).is_ok()
|
||||
}
|
||||
|
||||
fn archive_file_is_verified(path: &Path, file: &SherpaRemoteFile) -> bool {
|
||||
path.exists() && (file.size > 0 || file.sha256.is_some()) && verify_file(path, file).is_ok()
|
||||
}
|
||||
|
||||
fn verify_file(path: &Path, file: &SherpaRemoteFile) -> Result<()> {
|
||||
let meta =
|
||||
std::fs::metadata(path).with_context(|| format!("stat failed: {}", path.display()))?;
|
||||
@@ -786,3 +836,71 @@ fn emit_failed(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TempModelDir(PathBuf);
|
||||
|
||||
impl TempModelDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"openless-sherpa-download-{label}-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("create temp model dir");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempModelDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_downloaded_bytes_uses_extracted_assets_after_archive_removed() {
|
||||
let alias = "qwen3-asr-0.6b-int8";
|
||||
let archive = sherpa::release_archive_for_alias(alias).expect("release archive");
|
||||
let dir = TempModelDir::new("release-archive-extracted");
|
||||
fs::write(dir.path().join("conv_frontend.onnx"), b"abc").expect("write conv frontend");
|
||||
fs::write(dir.path().join("encoder.int8.onnx"), b"encod").expect("write encoder");
|
||||
fs::write(dir.path().join("decoder.int8.onnx"), b"decoder").expect("write decoder");
|
||||
fs::create_dir_all(dir.path().join("tokenizer")).expect("create tokenizer dir");
|
||||
fs::write(dir.path().join("tokenizer").join("tokenizer.json"), b"tok")
|
||||
.expect("write tokenizer file");
|
||||
|
||||
assert!(!dir.path().join(archive.file_name).exists());
|
||||
assert_eq!(
|
||||
downloaded_release_archive_bytes(dir.path(), alias, archive),
|
||||
18
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_finished_progress_uses_cached_bytes_as_total() {
|
||||
let alias = "qwen3-asr-0.6b-int8";
|
||||
let archive = sherpa::release_archive_for_alias(alias).expect("release archive");
|
||||
let dir = TempModelDir::new("release-archive-finished-progress");
|
||||
fs::write(dir.path().join("conv_frontend.onnx"), b"abc").expect("write conv frontend");
|
||||
fs::write(dir.path().join("encoder.int8.onnx"), b"encod").expect("write encoder");
|
||||
fs::write(dir.path().join("decoder.int8.onnx"), b"decoder").expect("write decoder");
|
||||
fs::create_dir_all(dir.path().join("tokenizer")).expect("create tokenizer dir");
|
||||
fs::write(dir.path().join("tokenizer").join("tokenizer.json"), b"tok")
|
||||
.expect("write tokenizer file");
|
||||
|
||||
let (downloaded, total) =
|
||||
finished_release_archive_progress_bytes(dir.path(), alias, archive);
|
||||
|
||||
assert_eq!(downloaded, 18);
|
||||
assert_eq!(total, downloaded);
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,15 @@ impl SherpaOnnxRuntime {
|
||||
for model in &mut catalog {
|
||||
let dir = sherpa::model_dir_for_alias(&model.alias)?;
|
||||
model.cached = sherpa::required_files_for_alias(&model.alias)
|
||||
.map(|files| files.iter().all(|file| dir.join(file).exists()))
|
||||
.map(|files| {
|
||||
files.iter().all(|file| {
|
||||
let path = dir.join(file);
|
||||
sherpa::required_path_is_valid(&model.alias, file, &path)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
model.downloaded_bytes =
|
||||
crate::asr::local::sherpa_download::downloaded_bytes(&model.alias);
|
||||
model.file_size_mb = model_dir_size_mb(&dir);
|
||||
}
|
||||
Ok(catalog)
|
||||
@@ -256,7 +263,7 @@ fn validate_alias(alias: &str) -> Result<()> {
|
||||
fn ensure_required_files(alias: &str, dir: &Path) -> Result<()> {
|
||||
for file in sherpa::required_files_for_alias(alias)? {
|
||||
let path = dir.join(file);
|
||||
if !path.exists() {
|
||||
if !sherpa::required_path_is_valid(alias, file, &path) {
|
||||
anyhow::bail!(
|
||||
"sherpa-onnx model file missing: {}. Place model files under {}",
|
||||
file,
|
||||
|
||||
@@ -78,6 +78,7 @@ pub fn get_default_style_system_prompts() -> StyleSystemPrompts {
|
||||
trait SettingsWriter {
|
||||
fn read_settings(&self) -> UserPreferences;
|
||||
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>;
|
||||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String>;
|
||||
fn refresh_dictation_hotkey(&self);
|
||||
fn refresh_qa_hotkey(&self);
|
||||
fn refresh_combo_hotkey(&self);
|
||||
@@ -95,6 +96,10 @@ impl SettingsWriter for Coordinator {
|
||||
self.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||||
self.sync_active_asr_provider_to_vault(provider)
|
||||
}
|
||||
|
||||
fn refresh_dictation_hotkey(&self) {
|
||||
self.update_hotkey_binding();
|
||||
}
|
||||
@@ -129,6 +134,10 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
|
||||
(**self).write_settings(prefs)
|
||||
}
|
||||
|
||||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||||
(**self).sync_active_asr_provider(provider)
|
||||
}
|
||||
|
||||
fn refresh_dictation_hotkey(&self) {
|
||||
(**self).refresh_dictation_hotkey();
|
||||
}
|
||||
@@ -168,7 +177,28 @@ fn persist_settings<T: SettingsWriter>(
|
||||
let translation_changed = previous.translation_hotkey != prefs.translation_hotkey;
|
||||
let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey;
|
||||
let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey;
|
||||
coord.write_settings(prefs)?;
|
||||
let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider;
|
||||
let active_asr_provider = prefs.active_asr_provider.clone();
|
||||
if active_asr_provider_changed {
|
||||
coord.sync_active_asr_provider(&active_asr_provider)?;
|
||||
}
|
||||
if let Err(error) = coord.write_settings(prefs.clone()) {
|
||||
if active_asr_provider_changed {
|
||||
if let Err(rollback_error) =
|
||||
coord.sync_active_asr_provider(&previous.active_asr_provider)
|
||||
{
|
||||
coord.write_settings(prefs).map_err(|roll_forward_error| {
|
||||
format!(
|
||||
"{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}"
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if dictation_shortcut_changed || dictation_mode_changed {
|
||||
coord.refresh_dictation_hotkey();
|
||||
}
|
||||
@@ -3309,6 +3339,11 @@ mod tests {
|
||||
#[derive(Default)]
|
||||
struct FakeSettingsWriter {
|
||||
saved: Mutex<Option<UserPreferences>>,
|
||||
active_asr_provider_syncs: Mutex<Vec<String>>,
|
||||
write_settings_error: Mutex<Option<String>>,
|
||||
write_settings_errors: Mutex<Vec<Option<String>>>,
|
||||
active_asr_provider_sync_error: Mutex<Option<String>>,
|
||||
active_asr_provider_sync_errors: Mutex<Vec<Option<String>>>,
|
||||
dictation_refreshes: Mutex<u32>,
|
||||
qa_refreshes: Mutex<u32>,
|
||||
combo_refreshes: Mutex<u32>,
|
||||
@@ -3576,10 +3611,49 @@ mod tests {
|
||||
}
|
||||
|
||||
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
|
||||
if let Some(error) = {
|
||||
let mut errors = self.write_settings_errors.lock().unwrap();
|
||||
if errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
errors.remove(0)
|
||||
}
|
||||
} {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = self.write_settings_error.lock().unwrap().clone() {
|
||||
return Err(error);
|
||||
}
|
||||
*self.saved.lock().unwrap() = Some(prefs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||||
self.active_asr_provider_syncs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(provider.to_string());
|
||||
if let Some(error) = {
|
||||
let mut errors = self.active_asr_provider_sync_errors.lock().unwrap();
|
||||
if errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
errors.remove(0)
|
||||
}
|
||||
} {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = self
|
||||
.active_asr_provider_sync_error
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_dictation_hotkey(&self) {
|
||||
*self.dictation_refreshes.lock().unwrap() += 1;
|
||||
}
|
||||
@@ -3768,7 +3842,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_skips_hotkey_refresh_when_shortcuts_unchanged() {
|
||||
fn persist_settings_syncs_active_asr_provider_without_hotkey_refresh() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let previous = UserPreferences::default();
|
||||
*writer.saved.lock().unwrap() = Some(previous.clone());
|
||||
@@ -3794,6 +3868,138 @@ mod tests {
|
||||
.expect("settings saved");
|
||||
assert_eq!(saved.active_asr_provider, prefs.active_asr_provider);
|
||||
assert_eq!(saved.microphone_device_name, prefs.microphone_device_name);
|
||||
assert_eq!(
|
||||
writer.active_asr_provider_syncs.lock().unwrap().clone(),
|
||||
vec![prefs.active_asr_provider.clone()]
|
||||
);
|
||||
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_does_not_save_when_active_asr_sync_fails() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let previous = UserPreferences::default();
|
||||
*writer.saved.lock().unwrap() = Some(previous.clone());
|
||||
*writer.active_asr_provider_sync_error.lock().unwrap() = Some("sync failed".to_string());
|
||||
let prefs = UserPreferences {
|
||||
active_asr_provider: "whisper".to_string(),
|
||||
microphone_device_name: "External Mic".to_string(),
|
||||
hotkey: previous.hotkey,
|
||||
dictation_hotkey: previous.dictation_hotkey,
|
||||
qa_hotkey: previous.qa_hotkey,
|
||||
translation_hotkey: previous.translation_hotkey,
|
||||
switch_style_hotkey: previous.switch_style_hotkey,
|
||||
open_app_hotkey: previous.open_app_hotkey,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = persist_settings(&writer, prefs).unwrap_err();
|
||||
|
||||
assert_eq!(error, "sync failed");
|
||||
let saved = writer
|
||||
.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("previous settings remain saved");
|
||||
assert_eq!(saved.active_asr_provider, previous.active_asr_provider);
|
||||
assert_eq!(saved.microphone_device_name, previous.microphone_device_name);
|
||||
assert_eq!(
|
||||
writer.active_asr_provider_syncs.lock().unwrap().clone(),
|
||||
vec!["whisper".to_string()]
|
||||
);
|
||||
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_restores_active_asr_provider_when_save_fails_after_sync() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let previous = UserPreferences::default();
|
||||
*writer.saved.lock().unwrap() = Some(previous.clone());
|
||||
*writer.write_settings_error.lock().unwrap() = Some("save failed".to_string());
|
||||
let prefs = UserPreferences {
|
||||
active_asr_provider: "whisper".to_string(),
|
||||
microphone_device_name: "External Mic".to_string(),
|
||||
hotkey: previous.hotkey,
|
||||
dictation_hotkey: previous.dictation_hotkey,
|
||||
qa_hotkey: previous.qa_hotkey,
|
||||
translation_hotkey: previous.translation_hotkey,
|
||||
switch_style_hotkey: previous.switch_style_hotkey,
|
||||
open_app_hotkey: previous.open_app_hotkey,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = persist_settings(&writer, prefs).unwrap_err();
|
||||
|
||||
assert_eq!(error, "save failed");
|
||||
let saved = writer
|
||||
.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("previous settings remain saved");
|
||||
assert_eq!(saved.active_asr_provider, previous.active_asr_provider);
|
||||
assert_eq!(saved.microphone_device_name, previous.microphone_device_name);
|
||||
assert_eq!(
|
||||
writer.active_asr_provider_syncs.lock().unwrap().clone(),
|
||||
vec![
|
||||
"whisper".to_string(),
|
||||
previous.active_asr_provider.clone()
|
||||
]
|
||||
);
|
||||
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_settings_keeps_new_active_asr_provider_when_rollback_fails() {
|
||||
let writer = FakeSettingsWriter::default();
|
||||
let previous = UserPreferences::default();
|
||||
*writer.saved.lock().unwrap() = Some(previous.clone());
|
||||
*writer.write_settings_errors.lock().unwrap() =
|
||||
vec![Some("save failed".to_string()), None];
|
||||
*writer.active_asr_provider_sync_errors.lock().unwrap() =
|
||||
vec![None, Some("rollback failed".to_string())];
|
||||
let prefs = UserPreferences {
|
||||
active_asr_provider: "whisper".to_string(),
|
||||
microphone_device_name: "External Mic".to_string(),
|
||||
hotkey: previous.hotkey,
|
||||
dictation_hotkey: previous.dictation_hotkey,
|
||||
qa_hotkey: previous.qa_hotkey,
|
||||
translation_hotkey: previous.translation_hotkey,
|
||||
switch_style_hotkey: previous.switch_style_hotkey,
|
||||
open_app_hotkey: previous.open_app_hotkey,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
persist_settings(&writer, prefs.clone()).expect("settings remain consistent");
|
||||
|
||||
let saved = writer
|
||||
.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("new settings saved");
|
||||
assert_eq!(saved.active_asr_provider, prefs.active_asr_provider);
|
||||
assert_eq!(saved.microphone_device_name, prefs.microphone_device_name);
|
||||
assert_eq!(
|
||||
writer.active_asr_provider_syncs.lock().unwrap().clone(),
|
||||
vec!["whisper".to_string(), previous.active_asr_provider.clone()]
|
||||
);
|
||||
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
|
||||
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0);
|
||||
|
||||
@@ -761,6 +761,16 @@ impl Coordinator {
|
||||
pub fn prefs(&self) -> &PreferencesStore {
|
||||
&self.inner.prefs
|
||||
}
|
||||
pub fn sync_active_asr_provider_from_preferences(&self) -> Result<(), String> {
|
||||
let provider = self.inner.prefs.get().active_asr_provider;
|
||||
self.sync_active_asr_provider_to_vault(&provider)
|
||||
}
|
||||
pub fn sync_active_asr_provider_to_vault(&self, provider: &str) -> Result<(), String> {
|
||||
if CredentialsVault::get_active_asr() == provider {
|
||||
return Ok(());
|
||||
}
|
||||
CredentialsVault::set_active_asr_provider(provider).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn style_packs(&self) -> &StylePackStore {
|
||||
&self.inner.style_packs
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ pub fn run() {
|
||||
));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let coordinator = Arc::new(coordinator::Coordinator::new());
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(error) = coordinator.sync_active_asr_provider_from_preferences() {
|
||||
log::warn!("[startup] sync active ASR provider from preferences failed: {error}");
|
||||
}
|
||||
let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
|
||||
@@ -400,6 +400,7 @@ export interface SherpaOnnxCatalogModel {
|
||||
alias: SherpaOnnxModelAlias
|
||||
displayName: string
|
||||
cached: boolean
|
||||
downloadedBytes: number
|
||||
fileSizeMb: number | null
|
||||
}
|
||||
|
||||
@@ -449,24 +450,28 @@ export function getSherpaOnnxAsrCatalog(): Promise<SherpaOnnxCatalogModel[]> {
|
||||
alias: "sense-voice-small-zh" as const,
|
||||
displayName: "SenseVoice Small",
|
||||
cached: false,
|
||||
downloadedBytes: 0,
|
||||
fileSizeMb: 230,
|
||||
},
|
||||
{
|
||||
alias: "paraformer-zh" as const,
|
||||
displayName: "Paraformer ZH",
|
||||
cached: false,
|
||||
downloadedBytes: 0,
|
||||
fileSizeMb: 220,
|
||||
},
|
||||
{
|
||||
alias: "whisper-small-multi" as const,
|
||||
displayName: "Whisper Small",
|
||||
cached: false,
|
||||
downloadedBytes: 0,
|
||||
fileSizeMb: 480,
|
||||
},
|
||||
{
|
||||
alias: "qwen3-asr-0.6b-int8" as const,
|
||||
displayName: "Qwen3-ASR 0.6B INT8",
|
||||
cached: false,
|
||||
downloadedBytes: 0,
|
||||
fileSizeMb: 700,
|
||||
},
|
||||
])
|
||||
@@ -490,11 +495,11 @@ export function setSherpaOnnxAsrLanguageHint(
|
||||
)
|
||||
}
|
||||
|
||||
export function prepareSherpaOnnxAsr(modelAlias?: string): Promise<void> {
|
||||
export function prepareSherpaOnnxAsr(modelAlias: string): Promise<string> {
|
||||
return invokeOrMock(
|
||||
"sherpa_onnx_asr_prepare",
|
||||
modelAlias ? { modelAlias } : undefined,
|
||||
() => undefined,
|
||||
{ modelAlias },
|
||||
() => `mock-${modelAlias}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -510,20 +515,14 @@ export function releaseSherpaOnnxAsr(): Promise<void> {
|
||||
return invokeOrMock("sherpa_onnx_asr_release", undefined, () => undefined)
|
||||
}
|
||||
|
||||
export function getSherpaOnnxAsrModelDir(modelAlias?: string): Promise<string> {
|
||||
return invokeOrMock(
|
||||
"sherpa_onnx_asr_model_dir",
|
||||
modelAlias ? { modelAlias } : undefined,
|
||||
() => "",
|
||||
)
|
||||
export function getSherpaOnnxAsrModelDir(modelAlias: string): Promise<string> {
|
||||
return invokeOrMock("sherpa_onnx_asr_model_dir", { modelAlias }, () => "")
|
||||
}
|
||||
|
||||
export function revealSherpaOnnxAsrModelDir(
|
||||
modelAlias?: string,
|
||||
): Promise<void> {
|
||||
export function revealSherpaOnnxAsrModelDir(modelAlias: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"sherpa_onnx_asr_reveal_model_dir",
|
||||
modelAlias ? { modelAlias } : undefined,
|
||||
{ modelAlias },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
@@ -539,7 +538,12 @@ export function deleteSherpaOnnxAsrModel(modelAlias: string): Promise<void> {
|
||||
export interface SherpaOnnxRemoteInfo {
|
||||
modelAlias: string
|
||||
mirror: string
|
||||
files: { path: string; sizeBytes: number }[]
|
||||
files: {
|
||||
path: string
|
||||
localPath: string
|
||||
size: number
|
||||
sha256?: string | null
|
||||
}[]
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
@@ -570,12 +574,10 @@ export function downloadSherpaOnnxAsrModel(
|
||||
)
|
||||
}
|
||||
|
||||
export function cancelSherpaOnnxAsrDownload(
|
||||
modelAlias?: string,
|
||||
): Promise<void> {
|
||||
export function cancelSherpaOnnxAsrDownload(modelAlias: string): Promise<void> {
|
||||
return invokeOrMock(
|
||||
"sherpa_onnx_asr_cancel_download",
|
||||
modelAlias ? { modelAlias } : undefined,
|
||||
{ modelAlias },
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1243,6 +1243,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
selectedSherpaCatalog?.cached !== true &&
|
||||
selectedSherpaDownloadProgress?.phase !== "finished" &&
|
||||
(selectedSherpaDownloadProgress?.bytesDownloaded ?? 0) > 0
|
||||
const canDeleteSelectedSherpa =
|
||||
selectedSherpaCatalog?.cached === true ||
|
||||
hasSherpaPartial ||
|
||||
(selectedSherpaCatalog?.downloadedBytes ?? 0) > 0
|
||||
const showSherpaDownloadProgress =
|
||||
isSherpaDownloading ||
|
||||
selectedSherpaDownloadProgress?.phase === "failed" ||
|
||||
@@ -2140,8 +2144,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
size="sm"
|
||||
disabled={
|
||||
sherpaBusy !== null ||
|
||||
(selectedSherpaCatalog?.cached !== true &&
|
||||
!hasSherpaPartial)
|
||||
!canDeleteSelectedSherpa
|
||||
}
|
||||
onClick={() => void handleDeleteSherpa()}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
@@ -31,6 +31,7 @@ const ASR_NAME_KEY_BY_ID: Record<string, string> = {
|
||||
groq: 'asrGroq',
|
||||
whisper: 'asrWhisper',
|
||||
'foundry-local-whisper': 'asrFoundryLocalWhisper',
|
||||
'sherpa-onnx-local': 'asrSherpaOnnxLocal',
|
||||
'local-qwen3': 'asrLocalQwen3',
|
||||
};
|
||||
|
||||
@@ -63,6 +64,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
arkConfigured: false,
|
||||
});
|
||||
const { prefs } = useHotkeySettings();
|
||||
const credentialsRequestSeq = useRef(0);
|
||||
|
||||
const refreshHistory = useCallback(() => {
|
||||
setHistoryError(false);
|
||||
@@ -74,19 +76,31 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshHistory();
|
||||
const refreshCredentials = useCallback(() => {
|
||||
const requestSeq = credentialsRequestSeq.current + 1;
|
||||
credentialsRequestSeq.current = requestSeq;
|
||||
setCredsError(false);
|
||||
getCredentials()
|
||||
.then(status => {
|
||||
if (requestSeq !== credentialsRequestSeq.current) return;
|
||||
setCreds(status);
|
||||
setCredsError(false);
|
||||
})
|
||||
.catch(error => {
|
||||
if (requestSeq !== credentialsRequestSeq.current) return;
|
||||
console.error('[overview] failed to load credentials status', error);
|
||||
setCredsError(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshHistory();
|
||||
}, [refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCredentials();
|
||||
}, [refreshCredentials, prefs?.activeAsrProvider, prefs?.activeLlmProvider]);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
Reference in new issue
Block a user