Merge pull request #523 from weikeyi/fix/sherpa-local-asr-state-sync-v2

Fix Sherpa local ASR state sync
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-23 22:29:52 +08:00
commit 466e7568b3
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,
+208 -2
View 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
}
+4
View File
@@ -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()
+20 -18
View File
@@ -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,
)
}
+5 -2
View File
@@ -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()}
>
+17 -3
View File
@@ -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);