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:
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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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: '尺寸未知',
|
||||
},
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user