diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 14b26b864..a5c8a2019 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct HfApiCardData { + #[serde(default)] + summary: Option, +} + +/// 拉取 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 { + 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 { + 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 标签行(` String { + for block in markdown.split("\n\n") { + let block = block.trim(); + if block.is_empty() || block.starts_with("---") { + continue; + } + let mut parts: Vec = 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

\n \n

\n\n
\n

中文 | English

\n
\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('…')); + } } diff --git a/openless-all/app/src-tauri/src/commands/local_asr.rs b/openless-all/app/src-tauri/src/commands/local_asr.rs index 3809e2b65..4b01bc4b8 100644 --- a/openless-all/app/src-tauri/src/commands/local_asr.rs +++ b/openless-all/app/src-tauri/src/commands/local_asr.rs @@ -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, +) -> Result { + let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; + let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default(); + fetch_hf_card(id, m).await.map_err(|e| format!("{e:#}")) +} + #[tauri::command] pub fn local_asr_download_model( app: AppHandle, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 39d1507a1..82321a9e7 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -275,6 +275,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, diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 0c8fa0848..142c9c91d 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -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 (
{os === 'mac' && ( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f06187d62..1803a16e2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1482,10 +1482,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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ba8171265..f8c439672 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1450,10 +1450,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: '左側からモデルを選択', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3020b21b..1d26dfd9f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1450,10 +1450,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: '왼쪽에서 모델을 선택하세요', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e1237fa7d..71d97e87c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1480,10 +1480,16 @@ export const zhCN = { downloading: '下载中', startDownload: '开始下载', downloadNewModel: '下载新模型', - downloadDialogTitle: '选择本地 ASR 模型', + downloadDialogTitle: '下载模型', downloadDialogAlreadyHave: '该模型已下载,可直接使用', downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。', detailRepo: '模型仓库', + hfDownloads: '下载量', + hfLikes: '收藏数', + hfDescription: '模型简介', + hfNoDescription: '暂无简介', + hfCardFailed: '模型信息获取失败', + detailFiles: '个文件', detailDownloaded: '已下载', detailEmpty: '从左侧选择模型查看详情', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index de12e715a..907c1a4f5 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1448,10 +1448,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: '從左側選擇模型查看詳情', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 966ed7454..19becca8e 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -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 { + 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, diff --git a/openless-all/app/src/pages/LocalAsr/components.tsx b/openless-all/app/src/pages/LocalAsr/components.tsx index 362e411f9..412e456be 100644 --- a/openless-all/app/src/pages/LocalAsr/components.tsx +++ b/openless-all/app/src/pages/LocalAsr/components.tsx @@ -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(
{ + // 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({
- {/* 左侧:模型选择(竖排,结构与设置页侧栏一致) */} + {/* 左侧:模型选择(竖排)——与设置页左栏 rail 同风格, + 宽度对齐设置页(200px),让弹窗看起来和设置页是一体的 */}
{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)", }} > )}
- {/* 右侧:说明 + 详情(大小 / 文件数 / 状态) */} + {/* 右侧:模型信息 + HF 模型卡片(下载量/收藏/简介) */}
-
- {t("localAsr.downloadDialogDesc")} -
{selected ? (
@@ -1236,6 +1260,50 @@ export function DownloadDialog({ )}
+ {hfCard?.status === "loading" && ( +
+ {t("common.loading")} +
+ )} + {hfCard?.status === "error" && ( +
+ {t("localAsr.hfCardFailed")}: {hfCard.message} +
+ )} + {hfCard?.status === "ok" && ( +
+
+ + {t("localAsr.hfDownloads")}: {formatCount(hfCard.card.downloads)} + + + {t("localAsr.hfLikes")}: {formatCount(hfCard.card.likes)} + +
+ {hfCard.card.description ? ( + <> +
+ {t("localAsr.hfDescription")} +
+
+ {hfCard.card.description} +
+ + ) : ( +
+ {t("localAsr.hfNoDescription")} +
+ )} +
+ )} {selected.isDownloaded && (
{t("localAsr.downloadDialogAlreadyHave")} diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 4b1a109e1..aea079838 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -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>( {}, ) + // HF 模型卡片(下载量/收藏/简介)——弹窗右侧展示;成功结果缓存, + // 失败记 { loading:false, error } 允许重试。 + const [hfCards, setHfCards] = useState< + Record + >({}) const [error, setError] = useState(null) const [busyModelId, setBusyModelId] = useState(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(() => { 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 + } />
{ + 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)} /> diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 0935a2e34..3b443344c 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -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); } }