fix(local-asr): 下载弹窗全量目录 + 抖动/模糊修复 + HF 模型卡片 (#928)

* fix(local-asr): 下载弹窗全量目录 + 抖动/模糊修复 + HF 模型卡片

- 下载弹窗左侧空列表死锁:allSidebarEntries 把未下载模型过滤掉了,
  零下载用户弹窗内无模型可选、开始下载禁用;改为全目录展示,弹窗
  默认选中第一项,开始下载回退到第一个未下载条目
- 弹窗上下抖动 + 画面模糊/清晰闪烁:ol-window-enter 去掉 filter blur
  (fill-mode both 保留终帧 + will-change:filter 在 WKWebView 反复重栅
  格化),WindowChrome willChange 去 filter;弹窗卡片动画 spring 过冲
  改 --ol-motion-soft;弹窗打开时暂停 3s 轮询,遮罩后看板不再跳动
- 右侧直接展示 HF 模型卡片(HF 禁止 iframe 嵌入):新增
  local_asr_fetch_hf_card 命令(downloads/likes/cardData.summary,
  summary 缺失回退 README 首段),弹窗右侧显示下载量/收藏数/简介

* fix(local-asr): 下载弹窗遮罩误关 + 切换闪烁根治(去 backdrop-filter 死代码 + 卡片预加载)

- 画面「闪一下又恢复」合成层根源:WindowChrome 根节点 backdrop-filter
  blur(36px) 在非透明窗口(--ol-window-bg 不透明渐变)是纯开销死代码,
  macOS WKWebView 切换模型/下载中高频重渲染时合成层故障 → 整窗瞬间
  消失恢复。全平台统一 none,视觉无变化
- 下载弹窗遮罩误关:busyModelId 在下载启动后即清空(Rust 同步返回),
  下载中 busy 恒 false → 点遮罩下的「下载与存储设置」落在遮罩上弹窗
  直接关闭,像被按了叉。新增 anyDownloadInFlight(progress phase 判定)
  驱动弹窗 busy 与「+下载新模型」禁用,下载中遮罩点击不关闭
- 切换闪烁:弹窗打开时预加载全部条目 HF 卡片(缓存),切换选项右侧
  直接出数据,无「加载中→内容」替换
- 弹窗左栏对齐设置页:rail 背景 + 200px 宽度,条目去掉外边框

* fix(local-asr): 下载弹窗重写——去入场动画与卡片预加载(WKWebView 闪烁根因),标题改「下载模型」,README 简介提取增强

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-08-06 19:50:02 +08:00
1 parent b68a5901c3
commit 26f928cad3
13 files changed
+572 -43

No files matched your search

@@ -164,6 +164,205 @@ fn keep_file(path: &str) -> bool {
)
}
/// HF 模型卡片(下载量 / 收藏 / 简介)——下载弹窗右侧展示用。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HfModelCard {
pub model_id: String,
pub mirror: String,
pub downloads: u64,
pub likes: u64,
pub description: String,
}
#[derive(Debug, Deserialize)]
struct HfApiModelCard {
#[serde(default)]
downloads: u64,
#[serde(default)]
likes: u64,
#[serde(default, rename = "cardData")]
card_data: Option<HfApiCardData>,
}
#[derive(Debug, Deserialize)]
struct HfApiCardData {
#[serde(default)]
summary: Option<String>,
}
/// 拉取 HF 模型卡片:GET `{mirror}/api/models/{repo}` 拿 downloads / likes /
/// cardData.summary。summary 缺失时回退读 README 首个非空段落当简介;
/// 描述统一截断到 [`HF_CARD_DESC_MAX_CHARS`],防超长文本把弹窗撑爆。
pub async fn fetch_hf_card(model_id: ModelId, mirror: Mirror) -> Result<HfModelCard> {
let client = build_client()?;
let repo = model_id.hf_repo();
let url = format!("{}/api/models/{}", mirror.base_url(), repo);
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("HF model card API GET 失败: {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("HF model card API HTTP {}: {url}", resp.status());
}
let api: HfApiModelCard = resp
.json()
.await
.with_context(|| format!("HF model card JSON 解码失败: {url}"))?;
let mut description = api
.card_data
.as_ref()
.and_then(|c| c.summary.clone())
.unwrap_or_default();
if description.trim().is_empty() {
description = fetch_readme_first_paragraph(&client, repo, mirror).await?;
}
Ok(HfModelCard {
model_id: model_id.as_str().into(),
mirror: mirror.as_str().into(),
downloads: api.downloads,
likes: api.likes,
description: truncate_description(&description),
})
}
/// 拉取仓库 README 首个非空段落;README 缺失 / 非 200 / 无内容时返回空串。
async fn fetch_readme_first_paragraph(
client: &reqwest::Client,
repo: &str,
mirror: Mirror,
) -> Result<String> {
let url = format!("{}/{}/raw/main/README.md", mirror.base_url(), repo);
let resp = client.get(&url).send().await;
let text = match resp {
Ok(r) if r.status().is_success() => r.text().await.unwrap_or_default(),
_ => return Ok(String::new()),
};
Ok(first_readme_paragraph(&text))
}
/// 简介最大字符数(按 char 计,避免切在 UTF-8 中间)。
pub(crate) const HF_CARD_DESC_MAX_CHARS: usize = 280;
/// 纯函数:README markdown → 首个有实质内容的段落。跳过 yaml front-matter、
/// 标题行(`#` 开头)、图片(`!` 开头)、表格(`|` 开头)、分隔线(`---`)、
/// HTML 标签行(`<div`/`<p`/`<img`…,badges 区常见)与整行为 markdown 链接
/// 的行(`[![badge](…)](…)` / `[中文](…)` 语言切换行);段落内多行合并成
/// 一句,并剥掉行内链接 / 强调符。便于单测。
pub(crate) fn first_readme_paragraph(markdown: &str) -> String {
for block in markdown.split("\n\n") {
let block = block.trim();
if block.is_empty() || block.starts_with("---") {
continue;
}
let mut parts: Vec<String> = Vec::new();
for raw_line in block.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with('!')
|| line.starts_with('|')
|| line.starts_with("---")
|| line.starts_with('<')
|| is_link_only_line(line)
{
continue;
}
let stripped = strip_markdown_inline(line);
if !stripped.is_empty() {
parts.push(stripped);
}
}
if parts.is_empty() {
continue;
}
return truncate_description(&parts.join(" "));
}
String::new()
}
/// 整行是否只有 markdown 链接(badges 链 `[![a](u)](v)`、语言切换行
/// `[中文](url) | [English](url)`)。逐个剥离 `[text](url)`,检查链接之间
/// 与行首尾只允许纯分隔符(`|`、逗号、顿号、空白);badge 链(img.shields.io)
/// 剥不干净(嵌套 `]` 残留括号碎片),直接按特征跳过。
fn is_link_only_line(line: &str) -> bool {
if line.contains("img.shields.io") || line.trim_start().starts_with("[![") {
return true;
}
let mut rest = line;
loop {
let Some(open) = rest.find('[') else { break };
if !is_separator_only(&rest[..open]) {
return false;
}
let tail = &rest[open + 1..];
let Some(close) = tail.find("](") else {
return false;
};
let after = &tail[close + 2..];
let Some(end) = after.find(')') else {
return false;
};
rest = &after[end + 1..];
}
is_separator_only(rest)
}
/// 片段是否只含分隔符 / 空白(链接行允许的行首、行尾与链接间间隔)。
fn is_separator_only(s: &str) -> bool {
s.chars()
.all(|c| c.is_whitespace() || matches!(c, '|' | ',' | '·' | '、'))
}
/// 剥掉行内 markdown 语法,保留链接显示文本:`[text](url)` → `text`、
/// `![alt](url)` → 空(`!` 在 `[` 前面,图片 alt 不保留)、
/// `` `code` `` / `**bold**` / `*italic*` / `_x_` → 裸文本。
fn strip_markdown_inline(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find('[') {
out.push_str(&rest[..open]);
let tail = &rest[open + 1..];
if let Some(close) = tail.find("](") {
let text = &tail[..close];
let after = &tail[close + 2..];
if let Some(end) = after.find(')') {
let is_image = out.ends_with('!');
if is_image {
out.pop(); // 图片标记 `!` 在链接外,随 alt 一起丢弃
}
let text = text.trim();
if !is_image && !text.is_empty() {
out.push_str(text);
}
rest = &after[end + 1..];
continue;
}
}
// 不是链接结构的 `[`:原样保留继续扫。
out.push('[');
rest = tail;
}
out.push_str(rest);
out.replace("**", "")
.replace('`', "")
.replace('*', "")
.replace('_', "")
}
/// 纯函数:描述截断到 [`HF_CARD_DESC_MAX_CHARS`],超长加省略号。
pub(crate) fn truncate_description(text: &str) -> String {
let text = text.trim();
if text.chars().count() <= HF_CARD_DESC_MAX_CHARS {
return text.to_string();
}
let truncated: String = text.chars().take(HF_CARD_DESC_MAX_CHARS).collect();
format!("{truncated}…")
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
@@ -425,9 +624,7 @@ async fn run_download(
// 节流:距上次 emit < 150ms 的中间进度直接丢弃(高频事件会让
// 前端进度条抽搐),in_flight 仍照常累计,下次 emit 带的是最新值。
let now = now_millis();
if now - last_emit.load(Ordering::Relaxed)
< PROGRESS_EMIT_MIN_INTERVAL_MS
{
if now - last_emit.load(Ordering::Relaxed) < PROGRESS_EMIT_MIN_INTERVAL_MS {
return;
}
last_emit.store(now, Ordering::Relaxed);
@@ -1077,7 +1274,11 @@ fn emit_cancelled(
#[cfg(test)]
mod tests {
use super::{existing_file_is_complete, remove_partial_artifacts};
use super::{
existing_file_is_complete, first_readme_paragraph, is_link_only_line,
remove_partial_artifacts, strip_markdown_inline, truncate_description,
HF_CARD_DESC_MAX_CHARS,
};
#[test]
fn complete_when_size_matches() {
@@ -1126,4 +1327,89 @@ mod tests {
assert!(keep.exists(), "未在清单里的文件不应被删除");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn first_readme_paragraph_skips_front_matter_and_headers() {
let md = "---\nlicense: apache-2.0\n---\n\n# Qwen3-ASR\n\nThis is the first real paragraph.\n\n## Features\n- fast\n- accurate";
assert_eq!(
first_readme_paragraph(md),
"This is the first real paragraph."
);
}
#[test]
fn first_readme_paragraph_joins_multiline_paragraph() {
let md = "# Title\n\nFirst line continues\nonto the second line.\n\n## Next";
assert_eq!(
first_readme_paragraph(md),
"First line continues onto the second line."
);
}
#[test]
fn first_readme_paragraph_returns_empty_when_only_markup() {
let md = "# Only headers\n\n---\n\n![image](x.png)";
assert_eq!(first_readme_paragraph(md), "");
}
#[test]
fn first_readme_paragraph_skips_html_badge_lines() {
// Qwen3 README 实际结构:HTML 包裹的 badge 区 + 徽章链接行 + 正文。
let md = "# Qwen3\n\n<p align=\"center\">\n <img src=\"qwen.png\" width=\"400\">\n</p>\n\n<div align=\"center\">\n <h4> <a href=\"#\">中文</a> | <a href=\"#\">English</a> </h4>\n</div>\n\n[![Model License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)\n\nQwen3 is a next-generation open model.";
assert_eq!(
first_readme_paragraph(md),
"Qwen3 is a next-generation open model."
);
}
#[test]
fn first_readme_paragraph_skips_link_only_lines() {
// 语言切换行与 badge 链整行都是纯链接,不应当正文。
assert!(is_link_only_line(
"[中文](https://a.cn) | [English](https://a.io)"
));
assert!(is_link_only_line(
"[![badge](https://img.shields.io/badge/a-1.svg)](https://x)"
));
assert!(!is_link_only_line(
"See the [docs](https://d.io) for details"
));
}
#[test]
fn strip_markdown_inline_keeps_link_text_drops_markup() {
assert_eq!(
strip_markdown_inline("See [Qwen3](https://hf.co/Qwen/Qwen3) docs"),
"See Qwen3 docs"
);
assert_eq!(strip_markdown_inline("![logo](logo.png)"), "");
assert_eq!(
strip_markdown_inline("**bold** and `code` and _em_"),
"bold and code and em"
);
}
#[test]
fn first_readme_paragraph_strips_inline_links_and_emphasis() {
let md =
"# Title\n\nCheck the **official** [Qwen3](https://hf.co/Qwen/Qwen3) page for details.";
assert_eq!(
first_readme_paragraph(md),
"Check the official Qwen3 page for details."
);
}
#[test]
fn truncate_description_keeps_short_text() {
assert_eq!(truncate_description("hello world"), "hello world");
assert_eq!(truncate_description(" padded "), "padded");
}
#[test]
fn truncate_description_cuts_long_text() {
let long = "界".repeat(HF_CARD_DESC_MAX_CHARS + 50);
let out = truncate_description(&long);
assert_eq!(out.chars().count(), HF_CARD_DESC_MAX_CHARS + 1); // +1 省略号
assert!(out.ends_with('…'));
}
}
@@ -1,7 +1,7 @@
use super::*;
use crate::asr::local::{
download::{fetch_remote_info, RemoteInfo},
download::{fetch_hf_card, fetch_remote_info, HfModelCard, RemoteInfo},
DownloadManager, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID,
};
@@ -201,6 +201,16 @@ pub async fn local_asr_fetch_remote_info(
fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn local_asr_fetch_hf_card(
model_id: String,
mirror: Option<String>,
) -> Result<HfModelCard, 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_hf_card(id, m).await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn local_asr_download_model(
app: AppHandle,
+1
View File
@@ -277,6 +277,7 @@ macro_rules! app_invoke_handler_desktop {
commands::local_asr_set_mirror,
commands::local_asr_list_models,
commands::local_asr_fetch_remote_info,
commands::local_asr_fetch_hf_card,
commands::local_asr_download_model,
commands::local_asr_cancel_download,
commands::local_asr_delete_model,
@@ -40,6 +40,12 @@ export function WindowChrome({
const useSolidSurface = os === 'linux' || os === 'android';
// 主窗口底色是不透明渐变(--ol-window-bg),backdrop-filter 模糊不到任何
// 内容(非透明窗口拿不到窗口背后的像素,见 global.css .ol-frost 注释)——
// 之前 blur(36px) 是纯合成开销死代码,macOS WKWebView 在切换模型/高频
// 重渲染时合成层故障,整窗「消失一下又恢复」。全平台统一 none。
const useBackdropFilter = false;
return (
<div
className="ol-winchrome"
@@ -57,11 +63,11 @@ export function WindowChrome({
flexDirection: 'column',
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid var(--ol-window-border)',
background: useSolidSurface ? 'var(--ol-surface)' : 'var(--ol-window-bg)',
backdropFilter: useSolidSurface ? 'none' : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: useSolidSurface ? 'none' : 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
backdropFilter: useBackdropFilter ? 'blur(var(--ol-glass-blur-strong)) saturate(190%)' : 'none',
WebkitBackdropFilter: useBackdropFilter ? 'blur(var(--ol-glass-blur-strong)) saturate(190%)' : 'none',
animation: os === 'win' ? undefined : 'ol-window-enter 0.42s var(--ol-motion-spring) both',
transition: 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft), backdrop-filter 0.28s var(--ol-motion-soft)',
willChange: 'opacity, transform, filter',
transition: 'box-shadow 0.28s var(--ol-motion-soft), border-color 0.28s var(--ol-motion-soft)',
willChange: 'opacity, transform',
} as CSSProperties}
>
{os === 'mac' && (
+7 -1
View File
@@ -1497,10 +1497,16 @@ export const en: typeof zhCN = {
downloading: 'Downloading',
startDownload: 'Start download',
downloadNewModel: 'Download new model',
downloadDialogTitle: 'Select a local ASR model',
downloadDialogTitle: 'Download Model',
downloadDialogAlreadyHave: 'Already downloaded — ready to use',
downloadDialogDesc: 'Pick a model on the left, then click "Start download". Once downloaded, select the local model provider in ASR Transcription (Services → AI Providers) to use it.',
detailRepo: 'Repository',
hfDownloads: 'Downloads',
hfLikes: 'Likes',
hfDescription: 'About',
hfNoDescription: 'No description yet',
hfCardFailed: 'Failed to load model info',
detailFiles: 'files',
detailDownloaded: 'Downloaded',
detailEmpty: 'Select a model on the left to see details',
+7 -1
View File
@@ -1465,10 +1465,16 @@ export const ja: typeof zhCN = {
downloading: 'ダウンロード中',
startDownload: 'ダウンロード開始',
downloadNewModel: '新しいモデルをダウンロード',
downloadDialogTitle: 'ローカル ASR モデルを選択',
downloadDialogTitle: 'モデルをダウンロード',
downloadDialogAlreadyHave: 'ダウンロード済みです',
downloadDialogDesc: '左のモデルを選択して「ダウンロード開始」をクリックします。完了後、「サービス → AI プロバイダー → ASR 文字起こし」でローカルモデルのプロバイダーを選択すると利用できます。',
detailRepo: 'リポジトリ',
hfDownloads: 'ダウンロード数',
hfLikes: 'いいね',
hfDescription: 'モデル紹介',
hfNoDescription: '紹介文はありません',
hfCardFailed: 'モデル情報の取得に失敗しました',
detailFiles: 'ファイル',
detailDownloaded: 'ダウンロード済み',
detailEmpty: '左側からモデルを選択',
+7 -1
View File
@@ -1465,10 +1465,16 @@ export const ko: typeof zhCN = {
downloading: '다운로드 중',
startDownload: '다운로드 시작',
downloadNewModel: '새 모델 다운로드',
downloadDialogTitle: '로컬 ASR 모델 선택',
downloadDialogTitle: '모델 다운로드',
downloadDialogAlreadyHave: '이미 다운로드됨 — 바로 사용 가능',
downloadDialogDesc: '왼쪽에서 모델을 선택하고 「다운로드 시작」을 클릭하세요. 완료 후 「서비스 → AI 공급자 → ASR 음성 전사」에서 로컬 모델 공급자를 선택하면 사용할 수 있습니다.',
detailRepo: '저장소',
hfDownloads: '다운로드 수',
hfLikes: '좋아요',
hfDescription: '모델 소개',
hfNoDescription: '소개가 없습니다',
hfCardFailed: '모델 정보를 불러오지 못했습니다',
detailFiles: '개 파일',
detailDownloaded: '다운로드됨',
detailEmpty: '왼쪽에서 모델을 선택하세요',
+7 -1
View File
@@ -1495,10 +1495,16 @@ export const zhCN = {
downloading: '下载中',
startDownload: '开始下载',
downloadNewModel: '下载新模型',
downloadDialogTitle: '选择本地 ASR 模型',
downloadDialogTitle: '下载模型',
downloadDialogAlreadyHave: '该模型已下载,可直接使用',
downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。',
detailRepo: '模型仓库',
hfDownloads: '下载量',
hfLikes: '收藏数',
hfDescription: '模型简介',
hfNoDescription: '暂无简介',
hfCardFailed: '模型信息获取失败',
detailFiles: '个文件',
detailDownloaded: '已下载',
detailEmpty: '从左侧选择模型查看详情',
+7 -1
View File
@@ -1463,10 +1463,16 @@ export const zhTW: typeof zhCN = {
downloading: '下載中',
startDownload: '開始下載',
downloadNewModel: '下載新模型',
downloadDialogTitle: '選擇本地 ASR 模型',
downloadDialogTitle: '下載模型',
downloadDialogAlreadyHave: '該模型已下載,可直接使用',
downloadDialogDesc: '選擇左側模型後點擊「開始下載」。下載完成後,到「服務 → AI 提供商 → ASR 語音轉寫」選擇本地模型供應商即可使用。',
detailRepo: '模型倉庫',
hfDownloads: '下載量',
hfLikes: '收藏數',
hfDescription: '模型簡介',
hfNoDescription: '暫無簡介',
hfCardFailed: '模型資訊取得失敗',
detailFiles: '個檔案',
detailDownloaded: '已下載',
detailEmpty: '從左側選擇模型查看詳情',
+26
View File
@@ -264,6 +264,32 @@ export function fetchLocalAsrRemoteInfo(
)
}
/** HF 模型卡片:下载量 / 收藏 / 简介(下载弹窗右侧展示)。 */
export interface HfModelCard {
modelId: string
mirror: string
downloads: number
likes: number
description: string
}
export function fetchLocalAsrHfCard(
modelId: string,
mirror?: string,
): Promise<HfModelCard> {
return invokeOrMock(
"local_asr_fetch_hf_card",
{ modelId, mirror },
() => ({
modelId,
mirror: mirror ?? "huggingface",
downloads: 0,
likes: 0,
description: "",
}),
)
}
export function downloadLocalAsrModel(
modelId: string,
mirror?: string,
@@ -7,6 +7,7 @@ import { createPortal } from "react-dom"
import { useTranslation } from "react-i18next"
import {
type FoundryPrepareProgress,
type HfModelCard,
type LocalAsrDownloadProgress,
type LocalAsrModelStatus,
type LocalAsrTestResult,
@@ -775,6 +776,11 @@ function IconCheck() {
)
}
/** 下载量/收藏数展示:千分位分隔(12345 → "12,345")。 */
function formatCount(n: number): string {
return n.toLocaleString("en-US")
}
/** 右侧详情看板:选中模型的信息(HF 抓取的尺寸/文件数)+ 操作按钮。 */
export function ModelDetailPanel({
entry,
@@ -1015,6 +1021,7 @@ export function DownloadDialog({
onSelect,
sizeOf,
fileCountOf,
hfCardOf,
busy,
onStart,
onClose,
@@ -1024,12 +1031,23 @@ export function DownloadDialog({
onSelect: (id: string) => void
sizeOf: (id: string) => number | null
fileCountOf: (id: string) => number | null
hfCardOf: (id: string) =>
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ok"; card: HfModelCard }
| null
busy: boolean
onStart: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const selected = entries.find((e) => e.id === selectedId) ?? null
// 默认选中第一项:看板可能什么都没选中(零下载用户),弹窗不能停在
// 「未选择」空态——高亮、右侧详情与「开始下载」都跟随该解析值。
const resolvedId = entries.some((e) => e.id === selectedId)
? selectedId
: (entries[0]?.id ?? null)
const selected = entries.find((e) => e.id === resolvedId) ?? null
const hfCard = selected ? hfCardOf(selected.id) : null
return createPortal(
<div
role="dialog"
@@ -1045,9 +1063,15 @@ export function DownloadDialog({
justifyContent: "center",
zIndex: 1000,
padding: 28,
animation: "ol-modal-backdrop-in 0.18s var(--ol-motion-soft)",
// 无入场动画:WKWebView 上遮罩/卡片的合成层动画(opacity/
// transform)叠加在弹窗打开瞬间的 setState 重渲染上,会被
// 反复重栅格化——用户感知为「弹窗闪一下」。淡入只有 0.2s,
// 收益为零,去掉最稳(#928 实测后回退)。
}}
onClick={(e) => {
// busy = 真实下载中(index 传 anyDownloadInFlight):下载中点击
// 遮罩不关闭——否则用户点遮罩下的设置项时弹窗会「像按了叉一样
// 消失」,误以为是设置页闪退。只能走右上角 ✕ 关闭。
if (e.target === e.currentTarget && !busy) onClose()
}}
>
@@ -1062,7 +1086,8 @@ export function DownloadDialog({
border: "0.5px solid var(--ol-line-strong)",
boxShadow: "var(--ol-shadow-xl)",
overflow: "hidden",
animation: "ol-modal-card-in 0.24s var(--ol-motion-spring)",
// 无入场动画,见上方遮罩注释:动画重放是「弹窗闪一下 /
// 上下动」的 WKWebView 合成层根源,去掉后纯静态出现。
}}
>
{/* 标题行:左标题 + 右 ✕ 关闭 */}
@@ -1120,17 +1145,19 @@ export function DownloadDialog({
</button>
</div>
<div style={{ display: "flex", minHeight: 0, flex: 1, overflow: "hidden" }}>
{/* 左侧:模型选择(竖排,结构与设置页侧栏一致) */}
{/* 左侧:模型选择(竖排)——与设置页左栏 rail 同风格,
宽度对齐设置页(200px),让弹窗看起来和设置页是一体的 */}
<div
style={{
width: 230,
width: 200,
flexShrink: 0,
borderRight: "0.5px solid var(--ol-line)",
padding: 12,
background: "var(--ol-settings-rail-bg)",
borderRight: "0.5px solid var(--ol-line-soft)",
padding: "14px 12px",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 6,
gap: 4,
}}
>
<div
@@ -1138,7 +1165,7 @@ export function DownloadDialog({
fontSize: 12,
fontWeight: 600,
color: "var(--ol-ink-4)",
marginBottom: 2,
marginBottom: 6,
}}
>
{t("localAsr.sidebarTitle")}
@@ -1152,15 +1179,15 @@ export function DownloadDialog({
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
padding: "7px 10px",
borderRadius: 8,
border: "0.5px solid var(--ol-line-soft)",
border: 0,
background:
entry.id === selectedId
entry.id === resolvedId
? "var(--ol-segmented-active-bg)"
: "transparent",
boxShadow:
entry.id === selectedId
entry.id === resolvedId
? "var(--ol-segmented-active-shadow)"
: "none",
color: "var(--ol-ink)",
@@ -1169,7 +1196,7 @@ export function DownloadDialog({
textAlign: "left",
cursor: "pointer",
transition:
"background 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)",
"background 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)",
}}
>
<span
@@ -1195,12 +1222,9 @@ export function DownloadDialog({
</div>
)}
</div>
{/* 右侧:说明 + 详情(大小 / 文件数 / 状态) */}
{/* 右侧:模型信息 + HF 模型卡片(下载量/收藏/简介) */}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)", lineHeight: 1.6, marginBottom: 12 }}>
{t("localAsr.downloadDialogDesc")}
</div>
{selected ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--ol-ink)" }}>
@@ -1236,6 +1260,50 @@ export function DownloadDialog({
</span>
)}
</div>
{hfCard?.status === "loading" && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)" }}>
{t("common.loading")}
</div>
)}
{hfCard?.status === "error" && (
<div style={{ fontSize: 11.5, color: "#9b2c2c", lineHeight: 1.5 }}>
{t("localAsr.hfCardFailed")}: {hfCard.message}
</div>
)}
{hfCard?.status === "ok" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, fontSize: 11 }}>
<span style={{ padding: "2px 8px", borderRadius: 999, background: "rgba(0,0,0,0.05)", color: "var(--ol-ink-2)" }}>
{t("localAsr.hfDownloads")}: {formatCount(hfCard.card.downloads)}
</span>
<span style={{ padding: "2px 8px", borderRadius: 999, background: "rgba(0,0,0,0.05)", color: "var(--ol-ink-2)" }}>
{t("localAsr.hfLikes")}: {formatCount(hfCard.card.likes)}
</span>
</div>
{hfCard.card.description ? (
<>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)", letterSpacing: ".02em" }}>
{t("localAsr.hfDescription")}
</div>
<div style={{
fontSize: 12,
color: "var(--ol-ink-3)",
lineHeight: 1.65,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
{hfCard.card.description}
</div>
</>
) : (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)" }}>
{t("localAsr.hfNoDescription")}
</div>
)}
</div>
)}
{selected.isDownloaded && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)" }}>
{t("localAsr.downloadDialogAlreadyHave")}
+110 -9
View File
@@ -29,6 +29,7 @@ import {
deleteLocalAsrModel,
downloadLocalAsrModel,
downloadSherpaOnnxAsrModel,
fetchLocalAsrHfCard,
fetchLocalAsrRemoteInfo,
fetchSherpaOnnxAsrRemoteInfo,
getFoundryLocalAsrModelDir,
@@ -67,6 +68,7 @@ import {
type FoundryLocalAsrStatus,
type FoundryRuntimeSource,
type FoundryPrepareProgress,
type HfModelCard,
type LocalAsrDownloadProgress,
type LocalAsrEngineStatus,
type LocalAsrModelStatus,
@@ -138,6 +140,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const [remoteSizes, setRemoteSizes] = useState<Record<string, RemoteSize>>(
{},
)
// HF 模型卡片(下载量/收藏/简介)——弹窗右侧展示;成功结果缓存,
// 失败记 { loading:false, error } 允许重试。
const [hfCards, setHfCards] = useState<
Record<string, HfModelCard | { loading: boolean; error: string | null }>
>({})
const [error, setError] = useState<string | null>(null)
const [busyModelId, setBusyModelId] = useState<string | null>(null)
const [storageBusy, setStorageBusy] = useState(false)
@@ -480,6 +487,32 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
}
// HF 模型卡片按需抓取(弹窗选中模型时),成功结果缓存不重复请求。
const ensureHfCard = async (modelId: string, mirror: string) => {
const current = hfCards[modelId]
if (current) {
if (!("loading" in current)) return // 已有成功缓存
if (current.loading) return // 请求进行中
// 失败结果允许重试
}
setHfCards((prev) => ({
...prev,
[modelId]: { loading: true, error: null },
}))
try {
const card = await fetchLocalAsrHfCard(modelId, mirror)
setHfCards((prev) => ({ ...prev, [modelId]: card }))
} catch (e) {
setHfCards((prev) => ({
...prev,
[modelId]: {
loading: false,
error: e instanceof Error ? e.message : String(e),
},
}))
}
}
const ensureSherpaRemoteSize = async (
modelAlias: string,
mirror: string,
@@ -535,6 +568,18 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 下载弹窗打开期间暂停 3s 轮询:弹窗是静态目录选择,轮询 setState 会
// 重排遮罩后的看板内容,透过半透明遮罩看得到内容在跳。弹窗关闭后轮询
// 自动重启(依赖 downloadDialogOpen 的 effect 重建 interval)。
useEffect(() => {
if (downloadDialogOpen) return
const pollTimer = window.setInterval(() => {
void refresh()
}, 3000)
return () => window.clearInterval(pollTimer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen])
// 引擎状态改由后端主动 emit(加载/释放/keepLoadedSecs 变更),前端零轮询。
// 挂载时仍拉一次初值,之后 listen `local-asr:engine-changed` 增量更新。
// 仅 Tauri 环境(浏览器 dev mock 无事件)。
@@ -585,6 +630,17 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.mirror])
// 选中模型变化时按需拉 HF 模型卡片(只请求当前选中项,不做全目录预加载
// ——打开瞬间并行发多个网络请求 + setState 是 WKWebView 重栅格化闪烁的
// 峰值源)。成功结果缓存,切换回已加载的模型零请求;失败条目在此重试。
useEffect(() => {
if (!downloadDialogOpen || !selectedModelId || !settings) return
const entry = allSidebarEntries.find((e) => e.id === selectedModelId)
if (!entry?.repo) return
void ensureHfCard(selectedModelId, settings.mirror)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen, selectedModelId, settings?.mirror])
// 订阅下载进度事件 — 仅 Tauri 环境(浏览器 dev mock 无事件)。
useEffect(() => {
if (!isTauri) return
@@ -1462,6 +1518,17 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
const engineAvailable = settings?.engineAvailable ?? false
// 真实「下载中」判定:busyModelId 在下载启动后立即清空(Rust 命令同步返回,
// 下载跑在后端线程),下载中的可靠标志是 progress 条目的 phase。用于:
// 1) 下载弹窗 busy —— 遮罩点击不误关(用户点遮罩下的设置项时弹窗不能
// 「像按了叉一样消失」);2) 「+ 下载新模型」按钮下载中禁用。
const anyDownloadInFlight =
Object.values(progress).some(
(p) => p.phase === "started" || p.phase === "progress",
) ||
Object.values(sherpaDownloadProgress).some(
(p) => p.phase === "started" || p.phase === "progress",
)
const foundryPlatformAvailable = isWindowsLikePlatform()
const foundryAvailable =
foundryStatus?.available === true ||
@@ -1669,8 +1736,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)
// ─── 两栏看板的统一模型条目(Qwen3 / sherpa-onnx / foundry 归一化) ───
// allSidebarEntries = 全目录(下载弹窗用);sidebarEntries = 只列已下载 /
// 下载中的模型(看板用,未下载的走「+ 下载新模型」弹窗获取)。
// allSidebarEntries = 全目录(下载弹窗用,未下载/下载中/已下载全列出,
// 让「下载新模型」弹窗能选到所有可获取的模型);
// sidebarEntries = 只列已下载 / 下载中的模型(看板用,未下载的走
// 「+ 下载新模型」弹窗获取)。
const allSidebarEntries = useMemo<SidebarModelEntry[]>(() => {
const entries: SidebarModelEntry[] = []
// macOS:Qwen3 引擎
@@ -1679,7 +1748,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
Boolean(progress[m.id]) &&
(progress[m.id]?.phase === "started" ||
progress[m.id]?.phase === "progress")
if (!m.isDownloaded && !isDownloading) continue
entries.push({
id: m.id,
name: m.id,
@@ -1707,7 +1775,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
Boolean(sherpaDownloadProgress[c.alias]) &&
(sherpaDownloadProgress[c.alias]?.phase === "started" ||
sherpaDownloadProgress[c.alias]?.phase === "progress")
if (!c.cached && !isDownloading) continue
entries.push({
id: c.alias,
name: c.displayName || c.alias,
@@ -1738,7 +1805,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
(foundryProgress.phase === "runtime" ||
foundryProgress.phase === "model" ||
foundryProgress.phase === "load")
if (!c.cached && !isDownloading) continue
entries.push({
id: c.alias,
name: c.displayName || c.alias,
@@ -1778,6 +1844,21 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
[allSidebarEntries],
)
// 弹窗打开时若看板选中项不在全目录里(零下载用户未选中任何模型),把
// 弹窗默认高亮写回 selectedModelId——弹窗高亮与看板 state 一致,后续
// 切换 / 开始下载都基于同一值,没有「弹窗内显示 A、逻辑上是 B」的分叉。
useEffect(() => {
if (!downloadDialogOpen) return
const valid = allSidebarEntries.some((e) => e.id === selectedModelId)
if (valid) return
const fallback =
allSidebarEntries.find((e) => !e.isDownloaded) ??
allSidebarEntries[0] ??
null
setSelectedModelId(fallback?.id ?? null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen, allSidebarEntries, selectedModelId])
const selectedEntry =
sidebarEntries.find((e) => e.id === selectedModelId) ?? null
@@ -1827,10 +1908,13 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}
// 下载弹框「开始下载」:把弹框当前选中项分派到对应引擎的下载入口。
// 弹框列表是全目录(allSidebarEntries),选中项可能不在看板过滤列表里。
// 弹框列表是全目录(allSidebarEntries),选中项可能不在看板过滤列表里;
// 弹框默认选中第一项时 selectedModelId 可能还是 null,回退到第一个未下载条目。
const startDownloadFromDialog = () => {
const dialogEntry =
allSidebarEntries.find((e) => e.id === selectedModelId) ?? null
allSidebarEntries.find((e) => e.id === selectedModelId) ??
allSidebarEntries.find((e) => !e.isDownloaded) ??
null
if (!dialogEntry || dialogEntry.isDownloaded) return
dispatchEntryAction(dialogEntry, "download")
setDownloadDialogOpen(false)
@@ -1921,7 +2005,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
void refresh()
}}
onOpenDownload={() => setDownloadDialogOpen(true)}
downloadDisabled={busyModelId !== null || sherpaBusy !== null}
downloadDisabled={
busyModelId !== null ||
sherpaBusy !== null ||
anyDownloadInFlight
}
/> <div
style={{
paddingLeft: 16,
@@ -2307,7 +2395,20 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
: null
return remote?.fileCount ?? null
}}
busy={busyModelId !== null}
busy={busyModelId !== null || anyDownloadInFlight}
hfCardOf={(id) => {
const state = hfCards[id]
if (!state) return null
if ("loading" in state) {
return state.loading
? { status: "loading" as const }
: {
status: "error" as const,
message: state.error ?? "",
}
}
return { status: "ok" as const, card: state }
}}
onStart={startDownloadFromDialog}
onClose={() => setDownloadDialogOpen(false)}
/>
+3 -2
View File
@@ -87,16 +87,17 @@ input, textarea {
a { color: inherit; text-decoration: none; }
/* 窗口入场 —— 纯 opacity / transform(无 blur):WKWebView 对带 filter 的
常驻合成层(fill-mode: both 保留终帧)会反复重栅格化,整窗出现
「模糊→清晰」闪烁;去掉 blur 后即使动画被合成器重放也完全无感。 */
@keyframes ol-window-enter {
from {
opacity: 0;
transform: translate3d(0, 8px, 0) scale(0.992);
filter: blur(8px);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
}