feat(local-asr): 前端「模型设置」页 + Settings 预设 + 反硬编码重构 — P5

后端反硬编码(按用户反馈):
- models.rs 删 files()/approx_bytes(),改用哨兵文件 .openless-asr-ready
  判 is_downloaded、walk_dir 求和算 downloaded_bytes
- download.rs 加 fetch_remote_info(repo, mirror):实时 GET HF
  /api/models/<repo>/tree/main 拿真实文件清单 + 尺寸;过滤 .md/.png/.git*
  等非权重文件(白名单:json/safetensors/txt/bin/model/tiktoken)
- 新 IPC: local_asr_fetch_remote_info(modelId, mirror)
  → RemoteInfo { files: [{path,size}], totalBytes }

前端:
- LocalAsr.tsx 新页:状态卡 / 镜像源切换 / 模型列表 (下载/取消/删除/设为默认)
  + 进度条订阅 local-asr-download-progress 事件实时刷新
  + 真实尺寸通过 fetchLocalAsrRemoteInfo 拉取,**不硬编码**
- localAsr.ts: 全套 IPC 包装 + dev mock 分支
- FloatingShell.tsx + useAppState.ts: 加 'localAsr' tab,icon=archive
- Settings.tsx ASR_PRESETS 加 { id: 'local-qwen3', nameKey: 'asrLocalQwen3' }
- i18n zh+en 同步:nav.localAsr / asrLocalQwen3 / 整块 localAsr.* 文案
- types.ts UserPreferences 加 localAsrActiveModel + localAsrMirror
This commit is contained in:
baiqing committed 2026-05-05 09:39:26 +08:00
1 parent 4656ecc7c7
commit dd43a7801d
13 files changed
+817 -89

No files matched your search

@@ -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 `<mirror>/api/models/<repo>/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<RemoteFile>,
pub total_bytes: u64,
}
#[derive(Debug, Deserialize)]
struct HfTreeEntry {
#[serde(rename = "type")]
entry_type: String,
path: String,
#[serde(default)]
size: Option<u64>,
}
/// 拉远端文件清单(HF tree API)。供下载流程 + 前端"查看模型大小"按钮共用。
pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<RemoteInfo> {
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<Vec<RemoteFile>> {
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<HfTreeEntry> = resp
.json()
.await
.with_context(|| format!("HF tree JSON 解码失败: {url}"))?;
let files: Vec<RemoteFile> = 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,
},
@@ -1,7 +1,8 @@
//! 本地 Qwen3-ASR 模型注册表。
//! 本地 Qwen3-ASR 模型注册表(仅 id / 仓库名 / 显示名)。
//!
//! 文件清单复刻 antirez `download_model.sh` —— 不能漏,否则 `qwen_load`
//! 会失败。增加新模型时这里加一条 + 前端 i18n 加文案即可。
//! **文件清单与尺寸不再硬编码** —— 由 `download.rs` 在下载时从
//! `huggingface.co/api/models/<repo>/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<PathBuf> {
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<F: FnMut(u64)>(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<ModelStatus> {
.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),
})
@@ -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<ModelStatus> {
crate::asr::local::models::list_status()
}
/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸;
/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。
#[tauri::command]
pub async fn local_asr_fetch_remote_info(
model_id: String,
mirror: Option<String>,
) -> Result<RemoteInfo, String> {
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,
+1
View File
@@ -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,
@@ -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<Omit<NavItem, 'name'>> = [
{ 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';
+23
View File
@@ -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',
},
};
+23
View File
@@ -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: '尺寸未知',
},
};
+2
View File
@@ -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 = {
+125
View File
@@ -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<LocalAsrSettings> {
return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS);
}
export function setLocalAsrActiveModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined);
}
export function setLocalAsrMirror(mirror: string): Promise<void> {
return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined);
}
export function listLocalAsrModels(): Promise<LocalAsrModelStatus[]> {
return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS);
}
export function fetchLocalAsrRemoteInfo(
modelId: string,
mirror?: string,
): Promise<LocalAsrRemoteInfo> {
return invokeOrMock(
'local_asr_fetch_remote_info',
{ modelId, mirror },
() => ({
modelId,
mirror: mirror ?? 'huggingface',
files: [],
totalBytes: 0,
}),
);
}
export function downloadLocalAsrModel(
modelId: string,
mirror?: string,
): Promise<void> {
return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined);
}
export function cancelLocalAsrDownload(modelId: string): Promise<void> {
return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined);
}
export function deleteLocalAsrModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined);
}
+4
View File
@@ -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。
+404
View File
@@ -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<LocalAsrSettings | null>(null);
const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
const [progress, setProgress] = useState<Record<string, LocalAsrDownloadProgress>>({});
const [remoteSizes, setRemoteSizes] = useState<Record<string, RemoteSize>>({});
const [error, setError] = useState<string | null>(null);
const [busyModelId, setBusyModelId] = useState<string | null>(null);
const refreshTimer = useRef<number | null>(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<LocalAsrDownloadProgress>('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 (
<div style={{ padding: '20px 28px 32px', overflowY: 'auto', height: '100%' }}>
<PageHeader
kicker={t('localAsr.kicker')}
title={t('localAsr.title')}
desc={t('localAsr.desc')}
/>
{!engineAvailable && (
<Card style={{ marginBottom: 16, background: 'rgba(255, 235, 200, 0.4)' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)' }}>
{t('localAsr.engineUnavailable')}
</div>
</Card>
)}
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.mirrorLabel')}
</div>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
{t('localAsr.mirrorDesc')}
</div>
</div>
<select
value={settings?.mirror ?? 'huggingface'}
onChange={e => void handleMirrorChange(e.target.value)}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value="huggingface">{t('localAsr.mirrorHuggingface')}</option>
<option value="hf-mirror">{t('localAsr.mirrorHfMirror')}</option>
</select>
</div>
</Card>
{error && (
<Card style={{ marginBottom: 16, background: 'rgba(255, 220, 220, 0.5)' }}>
<div style={{ fontSize: 13, color: '#9b2c2c' }}>{error}</div>
</Card>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{models.map(model => (
<ModelRow
key={model.id}
model={model}
remoteSize={remoteSizes[model.id]}
progress={progress[model.id]}
isActive={settings?.activeModel === model.id}
engineAvailable={engineAvailable}
disabled={busyModelId !== null && busyModelId !== model.id}
onDownload={() => void handleDownload(model.id)}
onCancel={() => void handleCancel(model.id)}
onDelete={() => void handleDelete(model.id)}
onSetActive={() => void handleSetActiveModel(model.id)}
/>
))}
</div>
</div>
);
}
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 (
<Card>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{model.id}</div>
{isActive && <Pill tone="blue" size="sm">{t('localAsr.activeBadge')}</Pill>}
{model.isDownloaded && <Pill tone="ok" size="sm">{t('localAsr.downloadedBadge')}</Pill>}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>
{model.hfRepo} · {sizeLabel}
</div>
{showProgress && (
<div style={{ marginTop: 10, maxWidth: 420 }}>
<div
style={{
height: 6,
borderRadius: 3,
background: 'rgba(0,0,0,0.06)',
overflow: 'hidden',
}}>
<div
style={{
width: `${ratio * 100}%`,
height: '100%',
background:
progress?.phase === 'failed'
? '#d04545'
: progress?.phase === 'cancelled'
? 'var(--ol-ink-4)'
: 'var(--ol-accent-blue, #2c5cff)',
transition: 'width 120ms linear',
}}
/>
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>
{progress?.phase === 'failed'
? `${t('localAsr.failed')}: ${progress.error ?? ''}`
: progress?.phase === 'cancelled'
? t('localAsr.cancelled')
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file ? ` · ${progress.file}` : '')}
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{model.isDownloaded ? (
<>
{!isActive && (
<Btn
variant="blue"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onSetActive}>
{t('localAsr.setActive')}
</Btn>
)}
<Btn variant="ghost" size="sm" disabled={disabled} onClick={onDelete}>
{t('localAsr.delete')}
</Btn>
</>
) : isDownloading ? (
<Btn variant="ghost" size="sm" onClick={onCancel}>
{t('localAsr.cancel')}
</Btn>
) : (
<Btn
variant="primary"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onDownload}>
{t('localAsr.download')}
</Btn>
)}
</div>
</div>
</Card>
);
}
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`;
}
+2
View File
@@ -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'];
+8 -1
View File
@@ -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;