diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index bc8966eb..e6ef4c2e 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -1,10 +1,13 @@ //! Qwen3-ASR 模型下载管理。 //! -//! - 文件清单来自 `models.rs`,串行下载(一个模型 ≤7 个文件,并发收益小) -//! - 写入 `.partial` 后 `rename` 原子落盘;HF 不直接给文件级 sha256,所以 -//! 这里**不**做强校验(与 antirez `download_model.sh` 一致) -//! - 取消通过 `AtomicBool` 在每个 chunk 边界检查,drop 时自然终止 reqwest stream -//! - 进度通过 Tauri 事件 `local-asr-download-progress` 上报前端 +//! 流程: +//! 1. GET `/api/models//tree/main` 拿真实文件清单 + 尺寸 +//! 2. 过滤掉 .gitattributes / README 等非权重文件 +//! 3. 串行下载每个文件 → `.partial` → 原子 rename +//! 4. 全部成功后写哨兵 `.openless-asr-ready` 标记完整 +//! +//! 取消:每个 chunk 边界检查 `AtomicBool`;失败 / 取消保留 `.partial`, +//! 下次以 HTTP `Range` 头续传(与 antirez `download_model.sh` 行为对齐)。 use std::collections::HashMap; use std::path::Path; @@ -18,7 +21,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; use tokio::io::AsyncWriteExt; -use super::models::{model_dir, ModelId}; +use super::models::{model_dir, ModelId, READY_SENTINEL}; /// 下载源镜像。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -59,6 +62,95 @@ impl Mirror { } } +/// 远端单个文件描述(来自 HF tree API)。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteFile { + pub path: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInfo { + pub model_id: String, + pub mirror: String, + pub files: Vec, + pub total_bytes: u64, +} + +#[derive(Debug, Deserialize)] +struct HfTreeEntry { + #[serde(rename = "type")] + entry_type: String, + path: String, + #[serde(default)] + size: Option, +} + +/// 拉远端文件清单(HF tree API)。供下载流程 + 前端"查看模型大小"按钮共用。 +pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result { + let client = reqwest::Client::builder() + .build() + .context("build reqwest client failed")?; + let files = fetch_file_list(&client, model_id.hf_repo(), mirror).await?; + let total_bytes = files.iter().map(|f| f.size).sum(); + Ok(RemoteInfo { + model_id: model_id.as_str().into(), + mirror: mirror.as_str().into(), + files, + total_bytes, + }) +} + +async fn fetch_file_list( + client: &reqwest::Client, + repo: &str, + mirror: Mirror, +) -> Result> { + let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("HF tree API GET 失败: {url}"))?; + if !resp.status().is_success() { + anyhow::bail!("HF tree API HTTP {}: {url}", resp.status()); + } + let entries: Vec = resp + .json() + .await + .with_context(|| format!("HF tree JSON 解码失败: {url}"))?; + let files: Vec = entries + .into_iter() + .filter(|e| e.entry_type == "file" && keep_file(&e.path)) + .map(|e| RemoteFile { + path: e.path, + size: e.size.unwrap_or(0), + }) + .collect(); + if files.is_empty() { + anyhow::bail!("HF tree 返回空文件列表 (repo={repo})"); + } + Ok(files) +} + +/// 是否保留下载?过滤 docs / git-attribute / 图片。 +/// 白名单:模型权重 / 配置 / 词表用到的所有真实扩展名。 +fn keep_file(path: &str) -> bool { + if path.starts_with('.') { + return false; + } + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg") + { + return false; + } + let ext = lower.rsplit('.').next().unwrap_or(""); + matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken") +} + /// 进度事件 payload;前端按 `model_id` 过滤。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -140,9 +232,32 @@ async fn run_download( std::fs::create_dir_all(&dir) .with_context(|| format!("create model dir failed: {}", dir.display()))?; - let files = model_id.files(); - let file_count = files.len(); - let approx_total = model_id.approx_bytes(); + let client = reqwest::Client::builder() + .build() + .context("build reqwest client failed")?; + + // 第一步:拉真实文件清单 + 尺寸(不再硬编码)。 + let info = match fetch_remote_info(model_id, mirror).await { + Ok(i) => i, + Err(e) => { + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: String::new(), + file_index: 0, + file_count: 0, + bytes_downloaded: 0, + bytes_total: 0, + phase: DownloadPhase::Failed, + error: Some(format!("拉文件清单失败: {e:#}")), + }, + ); + return Err(e); + } + }; + let total_bytes = info.total_bytes; + let file_count = info.files.len(); emit( app, @@ -152,70 +267,82 @@ async fn run_download( file_index: 0, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, + bytes_total: total_bytes, phase: DownloadPhase::Started, error: None, }, ); - let client = reqwest::Client::builder() - .build() - .context("build reqwest client failed")?; - - for (idx, fname) in files.iter().enumerate() { + let mut bytes_done_before_current: u64 = 0; + for (idx, file) in info.files.iter().enumerate() { if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, fname, idx, file_count); + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); return Ok(()); } - let dest = dir.join(fname); + let dest = dir.join(&file.path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir failed: {}", parent.display()))?; + } if dest.exists() { + bytes_done_before_current += file.size; continue; } let url = format!( "{}/{}/resolve/main/{}", mirror.base_url(), model_id.hf_repo(), - fname + file.path ); - if let Err(e) = download_one(&client, &url, &dest, cancel, |bytes| { + let result = download_one(&client, &url, &dest, cancel, |bytes| { emit( app, DownloadProgress { model_id: model_id.as_str().into(), - file: (*fname).into(), + file: file.path.clone(), file_index: idx, file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id) + bytes, - bytes_total: approx_total, + bytes_downloaded: bytes_done_before_current + bytes, + bytes_total: total_bytes, phase: DownloadPhase::Progress, error: None, }, ); }) - .await - { - if cancel.load(Ordering::SeqCst) { - emit_cancelled(app, model_id, fname, idx, file_count); - return Ok(()); + .await; + match result { + Ok(()) => { + bytes_done_before_current += file.size; + } + Err(e) => { + if cancel.load(Ordering::SeqCst) { + emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes); + return Ok(()); + } + emit( + app, + DownloadProgress { + model_id: model_id.as_str().into(), + file: file.path.clone(), + file_index: idx, + file_count, + bytes_downloaded: super::models::downloaded_bytes(model_id), + bytes_total: total_bytes, + phase: DownloadPhase::Failed, + error: Some(format!("{e:#}")), + }, + ); + return Err(e); } - emit( - app, - DownloadProgress { - model_id: model_id.as_str().into(), - file: (*fname).into(), - file_index: idx, - file_count, - bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, - phase: DownloadPhase::Failed, - error: Some(format!("{e:#}")), - }, - ); - return Err(e); } } + // 全部成功 → 写哨兵 → is_downloaded 返回 true。 + let sentinel = dir.join(READY_SENTINEL); + std::fs::write(&sentinel, b"") + .with_context(|| format!("write sentinel failed: {}", sentinel.display()))?; + emit( app, DownloadProgress { @@ -224,7 +351,7 @@ async fn run_download( file_index: file_count, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: approx_total, + bytes_total: total_bytes, phase: DownloadPhase::Finished, error: None, }, @@ -295,6 +422,7 @@ fn emit_cancelled( fname: &str, idx: usize, file_count: usize, + total: u64, ) { emit( app, @@ -304,7 +432,7 @@ fn emit_cancelled( file_index: idx, file_count, bytes_downloaded: super::models::downloaded_bytes(model_id), - bytes_total: model_id.approx_bytes(), + bytes_total: total, phase: DownloadPhase::Cancelled, error: None, }, diff --git a/openless-all/app/src-tauri/src/asr/local/models.rs b/openless-all/app/src-tauri/src/asr/local/models.rs index a126d16a..a22df5e7 100644 --- a/openless-all/app/src-tauri/src/asr/local/models.rs +++ b/openless-all/app/src-tauri/src/asr/local/models.rs @@ -1,7 +1,8 @@ -//! 本地 Qwen3-ASR 模型注册表。 +//! 本地 Qwen3-ASR 模型注册表(仅 id / 仓库名 / 显示名)。 //! -//! 文件清单复刻 antirez `download_model.sh` —— 不能漏,否则 `qwen_load` -//! 会失败。增加新模型时这里加一条 + 前端 i18n 加文案即可。 +//! **文件清单与尺寸不再硬编码** —— 由 `download.rs` 在下载时从 +//! `huggingface.co/api/models//tree/main` 拉真实清单和大小。 +//! 增加新模型 = 这里加一条枚举 + 仓库名。 use std::path::PathBuf; @@ -10,6 +11,9 @@ use serde::Serialize; use crate::persistence; +/// 下载完成后落在模型目录里的哨兵文件名;存在 = 完整、可加载。 +pub(super) const READY_SENTINEL: &str = ".openless-asr-ready"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelId { Small06b, @@ -36,44 +40,13 @@ impl ModelId { &[ModelId::Small06b, ModelId::Large17b] } - /// HuggingFace repo id(用于拼下载 URL)。 + /// HuggingFace repo id(用于拼 API + 下载 URL)。 pub fn hf_repo(self) -> &'static str { match self { ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B", ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B", } } - - /// 该模型在 HF 仓库下需要拉的所有文件(顺序无所谓)。 - pub fn files(self) -> &'static [&'static str] { - match self { - ModelId::Small06b => &[ - "config.json", - "generation_config.json", - "model.safetensors", - "vocab.json", - "merges.txt", - ], - ModelId::Large17b => &[ - "config.json", - "generation_config.json", - "model.safetensors.index.json", - "model-00001-of-00002.safetensors", - "model-00002-of-00002.safetensors", - "vocab.json", - "merges.txt", - ], - } - } - - /// 大致体积(字节),用于前端进度条占位 + UI 显示。 - /// 数字来自 HF 仓库实测;不是精确校验,只用来估总和。 - pub fn approx_bytes(self) -> u64 { - match self { - ModelId::Small06b => 1_200 * 1024 * 1024, - ModelId::Large17b => 3_400 * 1024 * 1024, - } - } } /// 模型在本地的根目录(可能不存在)。 @@ -81,26 +54,49 @@ pub fn model_dir(id: ModelId) -> Result { Ok(persistence::local_models_root()?.join(id.as_str())) } -/// 检查所有必需文件是否齐全。 +/// 完整且可加载?= 哨兵存在。 +/// 比"枚举所有应有文件"稳:HF 仓库改文件名 / 加新文件时不会误报缺失。 pub fn is_downloaded(id: ModelId) -> bool { let dir = match model_dir(id) { Ok(d) => d, Err(_) => return false, }; - id.files().iter().all(|f| dir.join(f).exists()) + dir.join(READY_SENTINEL).exists() } -/// 已下载文件的总字节数(用于 UI 显示"X / Y MB")。 +/// 已落盘的字节数(walk_dir 求和)。下载中也能显示真实进度。 pub fn downloaded_bytes(id: ModelId) -> u64 { let dir = match model_dir(id) { Ok(d) => d, Err(_) => return 0, }; - id.files() - .iter() - .filter_map(|f| std::fs::metadata(dir.join(f)).ok()) - .map(|m| m.len()) - .sum() + let mut total: u64 = 0; + walk_files(&dir, &mut |size| total += size); + total +} + +fn walk_files(dir: &std::path::Path, on_size: &mut F) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + // 哨兵文件本身体积忽略不计,但路径过滤更直白:保留所有非空文件。 + if name == READY_SENTINEL { + continue; + } + match entry.file_type() { + Ok(ft) if ft.is_dir() => walk_files(&path, on_size), + Ok(ft) if ft.is_file() => { + if let Ok(meta) = entry.metadata() { + on_size(meta.len()); + } + } + _ => {} + } + } } #[derive(Debug, Clone, Serialize)] @@ -108,7 +104,6 @@ pub fn downloaded_bytes(id: ModelId) -> u64 { pub struct ModelStatus { pub id: String, pub hf_repo: String, - pub approx_bytes: u64, pub downloaded_bytes: u64, pub is_downloaded: bool, } @@ -119,7 +114,6 @@ pub fn list_status() -> Vec { .map(|&id| ModelStatus { id: id.as_str().to_string(), hf_repo: id.hf_repo().to_string(), - approx_bytes: id.approx_bytes(), downloaded_bytes: downloaded_bytes(id), is_downloaded: is_downloaded(id), }) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index e16c130f..77b2fd1d 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -691,6 +691,7 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { // ─────────────────────────── local ASR (Qwen3-ASR) ─────────────────────────── use crate::asr::local::{ + download::{fetch_remote_info, RemoteInfo}, DownloadManager, Mirror, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID, }; @@ -738,6 +739,18 @@ pub fn local_asr_list_models() -> Vec { crate::asr::local::models::list_status() } +/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸; +/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。 +#[tauri::command] +pub async fn local_asr_fetch_remote_info( + model_id: String, + mirror: Option, +) -> Result { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}")) +} + #[tauri::command] pub fn local_asr_download_model( app: AppHandle, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index d6ee93dc..31f3b1f4 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { commands::local_asr_set_active_model, commands::local_asr_set_mirror, commands::local_asr_list_models, + commands::local_asr_fetch_remote_info, commands::local_asr_download_model, commands::local_asr_cancel_download, commands::local_asr_delete_model, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 1d9f2fe3..8afc0a29 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -16,6 +16,7 @@ import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +import { LocalAsr } from '../pages/LocalAsr'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, @@ -45,6 +46,7 @@ const NAV_BASE: Array> = [ { id: 'style', icon: 'style', cmp: Style }, { id: 'translation', icon: 'translate', cmp: Translation }, { id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk }, + { id: 'localAsr', icon: 'archive', cmp: LocalAsr }, ]; const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 069b6f52..891a76a4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -58,6 +58,7 @@ export const en: typeof zhCN = { style: 'Style', translation: 'Translation', selectionAsk: 'Ask', + localAsr: 'Models', }, shell: { shortcutLabel: 'Recording shortcut', @@ -302,6 +303,7 @@ export const en: typeof zhCN = { asrZhipu: 'Zhipu GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper (compatible)', + asrLocalQwen3: 'Local Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', @@ -511,4 +513,25 @@ export const en: typeof zhCN = { rdev: 'rdev listener', }, }, + localAsr: { + kicker: 'LOCAL ASR', + title: 'Models', + desc: 'Local Qwen3-ASR engine and model manager. Models download from HuggingFace and run fully offline. Streaming on Windows is tracked in repo issues.', + engineUnavailable: 'The local engine is not bundled on this platform yet (macOS only; Windows tracked in issue #256). You can still download models, but they cannot be activated here.', + mirrorLabel: 'Download mirror', + mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', + mirrorHuggingface: 'HuggingFace official (huggingface.co)', + mirrorHfMirror: 'Mainland mirror (hf-mirror.com)', + activeBadge: 'In use', + downloadedBadge: 'Downloaded', + download: 'Download', + cancel: 'Cancel', + delete: 'Delete', + setActive: 'Set as default', + failed: 'Failed', + cancelled: 'Cancelled', + files: 'files', + sizeLoading: 'Fetching size…', + sizeUnknown: 'Size unknown', + }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d156ef1c..152fa563 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -56,6 +56,7 @@ export const zhCN = { style: '风格', translation: '翻译', selectionAsk: '划词追问', + localAsr: '模型设置', }, shell: { shortcutLabel: '录音快捷键', @@ -300,6 +301,7 @@ export const zhCN = { asrZhipu: '智谱 GLM-ASR', asrGroq: 'Groq Whisper-large-v3', asrWhisper: 'OpenAI Whisper(兼容)', + asrLocalQwen3: '本地 Qwen3-ASR', }, volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', @@ -509,4 +511,25 @@ export const zhCN = { rdev: 'rdev 监听器', }, }, + localAsr: { + kicker: '本地 ASR', + title: '模型设置', + desc: '本地 Qwen3-ASR 引擎与模型管理。模型从 HuggingFace 下载到本机,无需联网即可识别。Windows 端流式推理跟踪见仓库 issue。', + engineUnavailable: '当前平台暂未集成本地推理引擎(仅 macOS 已支持,Windows 端跟踪 issue #256)。可下载模型,但暂时无法启用。', + mirrorLabel: '下载镜像源', + mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', + mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', + mirrorHfMirror: '国内镜像 (hf-mirror.com)', + activeBadge: '当前使用', + downloadedBadge: '已下载', + download: '下载', + cancel: '取消', + delete: '删除', + setActive: '设为默认', + failed: '失败', + cancelled: '已取消', + files: '文件', + sizeLoading: '正在查询尺寸…', + sizeUnknown: '尺寸未知', + }, }; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4ae8cd79..5a36db26 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -53,6 +53,8 @@ const mockSettings: UserPreferences = { translationTargetLanguage: '', qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, qaSaveHistory: false, + localAsrActiveModel: 'qwen3-asr-0.6b', + localAsrMirror: 'huggingface', }; const mockHotkeyCapability: HotkeyCapability = { diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts new file mode 100644 index 00000000..63f8ee2a --- /dev/null +++ b/openless-all/app/src/lib/localAsr.ts @@ -0,0 +1,125 @@ +// localAsr.ts — IPC + 事件类型 for 本地 Qwen3-ASR 引擎与模型管理。 +// +// 后端命令定义:openless-all/app/src-tauri/src/commands.rs `local_asr_*` +// 事件:local-asr-download-progress / local-asr-token +// +// 注意:模型文件清单与尺寸不在此处硬编码 —— 通过 +// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。 + +import { invokeOrMock } from './ipc'; + +export type LocalAsrMirror = 'huggingface' | 'hf-mirror'; + +export interface LocalAsrSettings { + providerId: string; + activeModel: string; + mirror: string; + /** macOS 才编入 antirez/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */ + engineAvailable: boolean; +} + +export interface LocalAsrModelStatus { + id: string; + hfRepo: string; + downloadedBytes: number; + isDownloaded: boolean; +} + +export interface LocalAsrRemoteFile { + path: string; + size: number; +} + +export interface LocalAsrRemoteInfo { + modelId: string; + mirror: string; + files: LocalAsrRemoteFile[]; + totalBytes: number; +} + +export type LocalAsrDownloadPhase = + | 'started' + | 'progress' + | 'finished' + | 'cancelled' + | 'failed'; + +export interface LocalAsrDownloadProgress { + modelId: string; + file: string; + fileIndex: number; + fileCount: number; + bytesDownloaded: number; + bytesTotal: number; + phase: LocalAsrDownloadPhase; + error: string | null; +} + +const MOCK_SETTINGS: LocalAsrSettings = { + providerId: 'local-qwen3', + activeModel: 'qwen3-asr-0.6b', + mirror: 'huggingface', + engineAvailable: false, +}; + +const MOCK_MODELS: LocalAsrModelStatus[] = [ + { + id: 'qwen3-asr-0.6b', + hfRepo: 'Qwen/Qwen3-ASR-0.6B', + downloadedBytes: 0, + isDownloaded: false, + }, + { + id: 'qwen3-asr-1.7b', + hfRepo: 'Qwen/Qwen3-ASR-1.7B', + downloadedBytes: 0, + isDownloaded: false, + }, +]; + +export function getLocalAsrSettings(): Promise { + return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS); +} + +export function setLocalAsrActiveModel(modelId: string): Promise { + return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined); +} + +export function setLocalAsrMirror(mirror: string): Promise { + return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined); +} + +export function listLocalAsrModels(): Promise { + return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS); +} + +export function fetchLocalAsrRemoteInfo( + modelId: string, + mirror?: string, +): Promise { + return invokeOrMock( + 'local_asr_fetch_remote_info', + { modelId, mirror }, + () => ({ + modelId, + mirror: mirror ?? 'huggingface', + files: [], + totalBytes: 0, + }), + ); +} + +export function downloadLocalAsrModel( + modelId: string, + mirror?: string, +): Promise { + return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined); +} + +export function cancelLocalAsrDownload(modelId: string): Promise { + return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined); +} + +export function deleteLocalAsrModel(modelId: string): Promise { + return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined); +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index a8577fd1..2959789d 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -124,6 +124,10 @@ export interface UserPreferences { qaHotkey: QaHotkeyBinding | null; /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ qaSaveHistory: boolean; + /** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */ + localAsrActiveModel: string; + /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ + localAsrMirror: string; } /** Rust 通过 `qa:state` 事件下发的 payload。 diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx new file mode 100644 index 00000000..45260461 --- /dev/null +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -0,0 +1,404 @@ +// LocalAsr.tsx — 本地 Qwen3-ASR 模型管理页。 +// +// 功能: +// - 顶部:当前激活模型 + 镜像源切换 +// - 模型列表:每行模型 = 真实尺寸 / 进度 / [下载|取消|删除|设为默认] +// - 真实尺寸通过 fetchLocalAsrRemoteInfo 实时从 HuggingFace API 拉,**不硬编码** +// - 监听 `local-asr-download-progress` 事件实时刷新进度 +// - Win 端引擎不可用时禁用下载按钮,提示见 issue #256 + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isTauri, setActiveAsrProvider } from '../lib/ipc'; +import { + cancelLocalAsrDownload, + deleteLocalAsrModel, + downloadLocalAsrModel, + fetchLocalAsrRemoteInfo, + getLocalAsrSettings, + listLocalAsrModels, + setLocalAsrActiveModel, + setLocalAsrMirror, + type LocalAsrDownloadProgress, + type LocalAsrModelStatus, + type LocalAsrSettings, +} from '../lib/localAsr'; +import { Btn, Card, PageHeader, Pill } from './_atoms'; + +interface RemoteSize { + totalBytes: number; + fileCount: number; + loading: boolean; + error: string | null; +} + +export function LocalAsr() { + const { t } = useTranslation(); + const [settings, setSettings] = useState(null); + const [models, setModels] = useState([]); + const [progress, setProgress] = useState>({}); + const [remoteSizes, setRemoteSizes] = useState>({}); + const [error, setError] = useState(null); + const [busyModelId, setBusyModelId] = useState(null); + const refreshTimer = useRef(null); + + const refresh = async () => { + try { + setError(null); + const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); + setSettings(s); + setModels(list); + // 拉远端真实尺寸(每个模型一次,结果留缓存) + void Promise.all( + list.map(async m => { + await ensureRemoteSize(m.id, s.mirror); + }), + ); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const ensureRemoteSize = async (modelId: string, mirror: string) => { + setRemoteSizes(prev => { + if (prev[modelId] && !prev[modelId].error) return prev; + return { ...prev, [modelId]: { totalBytes: 0, fileCount: 0, loading: true, error: null } }; + }); + try { + const info = await fetchLocalAsrRemoteInfo(modelId, mirror); + setRemoteSizes(prev => ({ + ...prev, + [modelId]: { + totalBytes: info.totalBytes, + fileCount: info.files.length, + loading: false, + error: null, + }, + })); + } catch (e) { + setRemoteSizes(prev => ({ + ...prev, + [modelId]: { + totalBytes: 0, + fileCount: 0, + loading: false, + error: e instanceof Error ? e.message : String(e), + }, + })); + } + }; + + useEffect(() => { + void refresh(); + // refresh 内部已 fan-out 拉远端尺寸,不需要额外 effect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的, + // 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。 + useEffect(() => { + if (!settings) return; + setRemoteSizes({}); + void Promise.all( + models.map(m => ensureRemoteSize(m.id, settings.mirror)), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.mirror]); + + // 订阅下载进度事件 — 仅 Tauri 环境(浏览器 dev mock 无事件)。 + useEffect(() => { + if (!isTauri) return; + let unlisten: undefined | (() => void); + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const off = await listen('local-asr-download-progress', e => { + const payload = e.payload; + setProgress(prev => ({ ...prev, [payload.modelId]: payload })); + if ( + payload.phase === 'finished' || + payload.phase === 'cancelled' || + payload.phase === 'failed' + ) { + if (refreshTimer.current) window.clearTimeout(refreshTimer.current); + refreshTimer.current = window.setTimeout(() => { + void refresh(); + }, 200); + } + }); + if (cancelled) { + off(); + } else { + unlisten = off; + } + })().catch(err => console.warn('[localAsr] subscribe failed', err)); + return () => { + cancelled = true; + if (unlisten) unlisten(); + if (refreshTimer.current) window.clearTimeout(refreshTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSetActiveModel = async (modelId: string) => { + setBusyModelId(modelId); + try { + await setLocalAsrActiveModel(modelId); + // 顺手把 active provider 也切到本地(避免用户改了模型却忘了切 provider) + await setActiveAsrProvider('local-qwen3'); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleDownload = async (modelId: string) => { + setBusyModelId(modelId); + try { + await downloadLocalAsrModel(modelId, settings?.mirror); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleCancel = async (modelId: string) => { + try { + await cancelLocalAsrDownload(modelId); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const handleDelete = async (modelId: string) => { + setBusyModelId(modelId); + try { + await deleteLocalAsrModel(modelId); + setProgress(prev => { + const next = { ...prev }; + delete next[modelId]; + return next; + }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyModelId(null); + } + }; + + const handleMirrorChange = async (mirror: string) => { + try { + await setLocalAsrMirror(mirror); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const engineAvailable = settings?.engineAvailable ?? false; + + return ( +
+ + + {!engineAvailable && ( + +
+ {t('localAsr.engineUnavailable')} +
+
+ )} + + +
+
+
+ {t('localAsr.mirrorLabel')} +
+
+ {t('localAsr.mirrorDesc')} +
+
+ +
+
+ + {error && ( + +
{error}
+
+ )} + +
+ {models.map(model => ( + void handleDownload(model.id)} + onCancel={() => void handleCancel(model.id)} + onDelete={() => void handleDelete(model.id)} + onSetActive={() => void handleSetActiveModel(model.id)} + /> + ))} +
+
+ ); +} + +interface ModelRowProps { + model: LocalAsrModelStatus; + remoteSize?: RemoteSize; + progress?: LocalAsrDownloadProgress; + isActive: boolean; + engineAvailable: boolean; + disabled: boolean; + onDownload: () => void; + onCancel: () => void; + onDelete: () => void; + onSetActive: () => void; +} + +function ModelRow({ + model, + remoteSize, + progress, + isActive, + engineAvailable, + disabled, + onDownload, + onCancel, + onDelete, + onSetActive, +}: ModelRowProps) { + const { t } = useTranslation(); + const isDownloading = useMemo( + () => progress?.phase === 'started' || progress?.phase === 'progress', + [progress?.phase], + ); + const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes; + const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0; + const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0; + const showProgress = isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled'; + + const sizeLabel = remoteSize?.loading + ? t('localAsr.sizeLoading') + : remoteSize?.error + ? t('localAsr.sizeUnknown') + : remoteSize && remoteSize.totalBytes > 0 + ? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t('localAsr.files')}` + : t('localAsr.sizeUnknown'); + + return ( + +
+
+
+
{model.id}
+ {isActive && {t('localAsr.activeBadge')}} + {model.isDownloaded && {t('localAsr.downloadedBadge')}} +
+
+ {model.hfRepo} · {sizeLabel} +
+ {showProgress && ( +
+
+
+
+
+ {progress?.phase === 'failed' + ? `${t('localAsr.failed')}: ${progress.error ?? ''}` + : progress?.phase === 'cancelled' + ? t('localAsr.cancelled') + : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + + (progress?.file ? ` · ${progress.file}` : '')} +
+
+ )} +
+
+ {model.isDownloaded ? ( + <> + {!isActive && ( + + {t('localAsr.setActive')} + + )} + + {t('localAsr.delete')} + + + ) : isDownloading ? ( + + {t('localAsr.cancel')} + + ) : ( + + {t('localAsr.download')} + + )} +
+
+ + ); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 71c1abfd..8756c119 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -382,6 +382,8 @@ const ASR_PRESETS = [ { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, + // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 + { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, ] as const; type AsrPresetId = typeof ASR_PRESETS[number]['id']; diff --git a/openless-all/app/src/state/useAppState.ts b/openless-all/app/src/state/useAppState.ts index 423f6216..08d3b8dd 100644 --- a/openless-all/app/src/state/useAppState.ts +++ b/openless-all/app/src/state/useAppState.ts @@ -2,7 +2,14 @@ import { useState } from 'react'; -export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation' | 'selectionAsk'; +export type AppTab = + | 'overview' + | 'history' + | 'vocab' + | 'style' + | 'translation' + | 'selectionAsk' + | 'localAsr'; export interface AppState { currentTab: AppTab;