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:
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 链接
|
||||
/// 的行(`[](…)` / `[中文](…)` 语言切换行);段落内多行合并成
|
||||
/// 一句,并剥掉行内链接 / 强调符。便于单测。
|
||||
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 链 `[](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 不保留)、
|
||||
/// `` `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";
|
||||
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[](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(
|
||||
"[](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(""), "");
|
||||
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,
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '左側からモデルを選択',
|
||||
|
||||
@@ -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: '왼쪽에서 모델을 선택하세요',
|
||||
|
||||
@@ -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: '从左侧选择模型查看详情',
|
||||
|
||||
@@ -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: '從左側選擇模型查看詳情',
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user