From 40cb82340a4f11d96692de3d1141d6dc480f9d41 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 6 Aug 2026 05:32:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(local-asr):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=85=A8=E9=87=8F=E7=9B=AE=E5=BD=95=20+=20?= =?UTF-8?q?=E6=8A=96=E5=8A=A8/=E6=A8=A1=E7=B3=8A=E4=BF=AE=E5=A4=8D=20+=20H?= =?UTF-8?q?F=20=E6=A8=A1=E5=9E=8B=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 下载弹窗左侧空列表死锁: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 首段),弹窗右侧显示下载量/收藏数/简介 --- .../app/src-tauri/src/asr/local/download.rs | 166 +++++++++++++++++- .../app/src-tauri/src/commands/local_asr.rs | 12 +- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src/components/WindowChrome.tsx | 2 +- openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/lib/localAsr.ts | 26 +++ .../app/src/pages/LocalAsr/components.tsx | 74 +++++++- openless-all/app/src/pages/LocalAsr/index.tsx | 83 ++++++++- openless-all/app/src/styles/global.css | 5 +- 13 files changed, 374 insertions(+), 20 deletions(-) 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..7cda80c9c 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,129 @@ 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、 +/// 标题行(`#` 开头)、图片(`!` 开头)、表格(`|` 开头)与分隔线(`---`); +/// 段落内多行合并成一句。便于单测。 +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<&str> = 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("---") + { + continue; + } + parts.push(line); + } + if parts.is_empty() { + continue; + } + return truncate_description(&parts.join(" ")); + } + String::new() +} + +/// 纯函数:描述截断到 [`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 { @@ -1077,7 +1200,10 @@ 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, remove_partial_artifacts, + truncate_description, HF_CARD_DESC_MAX_CHARS, + }; #[test] fn complete_when_size_matches() { @@ -1126,4 +1252,42 @@ 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 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..30b089663 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -61,7 +61,7 @@ export function WindowChrome({ WebkitBackdropFilter: useSolidSurface ? 'none' : 'blur(var(--ol-glass-blur-strong)) saturate(190%)', 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', + willChange: 'opacity, transform', } as CSSProperties} > {os === 'mac' && ( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f06187d62..0a4961fb9 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1486,6 +1486,11 @@ export const en: typeof zhCN = { 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', + 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..4b45b40f3 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1454,6 +1454,11 @@ export const ja: typeof zhCN = { downloadDialogAlreadyHave: 'ダウンロード済みです', downloadDialogDesc: '左のモデルを選択して「ダウンロード開始」をクリックします。完了後、「サービス → AI プロバイダー → ASR 文字起こし」でローカルモデルのプロバイダーを選択すると利用できます。', detailRepo: 'リポジトリ', + hfDownloads: 'ダウンロード数', + hfLikes: 'いいね', + hfDescription: 'モデル紹介', + hfCardFailed: 'モデル情報の取得に失敗しました', + detailFiles: 'ファイル', detailDownloaded: 'ダウンロード済み', detailEmpty: '左側からモデルを選択', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3020b21b..3e6e5812a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1454,6 +1454,11 @@ export const ko: typeof zhCN = { downloadDialogAlreadyHave: '이미 다운로드됨 — 바로 사용 가능', downloadDialogDesc: '왼쪽에서 모델을 선택하고 「다운로드 시작」을 클릭하세요. 완료 후 「서비스 → AI 공급자 → ASR 음성 전사」에서 로컬 모델 공급자를 선택하면 사용할 수 있습니다.', detailRepo: '저장소', + hfDownloads: '다운로드 수', + hfLikes: '좋아요', + hfDescription: '모델 소개', + 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..9066e753b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1484,6 +1484,11 @@ export const zhCN = { downloadDialogAlreadyHave: '该模型已下载,可直接使用', downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。', detailRepo: '模型仓库', + hfDownloads: '下载量', + hfLikes: '收藏数', + hfDescription: '模型简介', + 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..ab2b6f20a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1452,6 +1452,11 @@ export const zhTW: typeof zhCN = { downloadDialogAlreadyHave: '該模型已下載,可直接使用', downloadDialogDesc: '選擇左側模型後點擊「開始下載」。下載完成後,到「服務 → AI 提供商 → ASR 語音轉寫」選擇本地模型供應商即可使用。', detailRepo: '模型倉庫', + hfDownloads: '下載量', + hfLikes: '收藏數', + hfDescription: '模型簡介', + 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..0330548e1 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(
{/* 标题行:左标题 + 右 ✕ 关闭 */} @@ -1156,11 +1177,11 @@ export function DownloadDialog({ borderRadius: 8, border: "0.5px solid var(--ol-line-soft)", 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)", @@ -1195,12 +1216,9 @@ export function DownloadDialog({
)} - {/* 右侧:说明 + 详情(大小 / 文件数 / 状态) */} + {/* 右侧:模型信息 + HF 模型卡片(下载量/收藏/简介) */}
-
- {t("localAsr.downloadDialogDesc")} -
{selected ? (
@@ -1236,6 +1254,46 @@ 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} +
+ + )} +
+ )} {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..ee211956a 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) @@ -193,6 +200,9 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const [engineStatus, setEngineStatus] = useState(null) const refreshTimer = useRef(null) + // 弹窗打开期间停掉 3s 轮询:轮询会 setState 重排遮罩后的看板内容, + // 透过半透明遮罩看得到内容在跳(配合 WKWebView 重栅格化更明显)。 + const downloadDialogOpenRef = useRef(false) const foundryRefreshTimer = useRef(null) const sherpaRefreshTimer = useRef(null) const sherpaDownloadRefreshTimer = useRef(null) @@ -480,6 +490,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, @@ -525,7 +561,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // 3s 轮询磁盘状态:模型被外部删除 / 下载中断时前端自动跟随(删除后 // 看板选中自动回落、下拉回到引擎级入口),不用等重开页面。qwen3 的 // list 是本地 fs walk,很轻;远端尺寸有缓存不会重复请求。 + // 下载弹窗打开时暂停——弹窗是静态目录选择,轮询的重渲染会让遮罩后 + // 的看板内容每 3s 跳动一次。 const pollTimer = window.setInterval(() => { + if (downloadDialogOpenRef.current) return void refresh() }, 3000) return () => { @@ -535,6 +574,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // 弹窗打开状态同步到 ref(上述 mount 闭包读不到最新 state)。 + useEffect(() => { + downloadDialogOpenRef.current = downloadDialogOpen + }, [downloadDialogOpen]) + // 引擎状态改由后端主动 emit(加载/释放/keepLoadedSecs 变更),前端零轮询。 // 挂载时仍拉一次初值,之后 listen `local-asr:engine-changed` 增量更新。 // 仅 Tauri 环境(浏览器 dev mock 无事件)。 @@ -585,6 +629,16 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings?.mirror]) + // 弹窗打开且选中模型(有 HF 仓库的)时抓取模型卡片(下载量/收藏/简介)。 + // 结果缓存;切换弹窗内选择时增量抓取。 + 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 @@ -1669,8 +1723,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 +1735,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 +1762,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 +1792,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, @@ -1827,10 +1880,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) @@ -2308,6 +2364,19 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { return remote?.fileCount ?? null }} busy={busyModelId !== null} + 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)} /> 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); } } From 71b26682b44e66d8fcf16a4786676ac56404c3a9 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 6 Aug 2026 17:40:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(local-asr):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E9=81=AE=E7=BD=A9=E8=AF=AF=E5=85=B3=20+=20?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=97=AA=E7=83=81=E6=A0=B9=E6=B2=BB=EF=BC=88?= =?UTF-8?q?=E5=8E=BB=20backdrop-filter=20=E6=AD=BB=E4=BB=A3=E7=A0=81=20+?= =?UTF-8?q?=20=E5=8D=A1=E7=89=87=E9=A2=84=E5=8A=A0=E8=BD=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 画面「闪一下又恢复」合成层根源:WindowChrome 根节点 backdrop-filter blur(36px) 在非透明窗口(--ol-window-bg 不透明渐变)是纯开销死代码, macOS WKWebView 切换模型/下载中高频重渲染时合成层故障 → 整窗瞬间 消失恢复。全平台统一 none,视觉无变化 - 下载弹窗遮罩误关:busyModelId 在下载启动后即清空(Rust 同步返回), 下载中 busy 恒 false → 点遮罩下的「下载与存储设置」落在遮罩上弹窗 直接关闭,像被按了叉。新增 anyDownloadInFlight(progress phase 判定) 驱动弹窗 busy 与「+下载新模型」禁用,下载中遮罩点击不关闭 - 切换闪烁:弹窗打开时预加载全部条目 HF 卡片(缓存),切换选项右侧 直接出数据,无「加载中→内容」替换 - 弹窗左栏对齐设置页:rail 背景 + 200px 宽度,条目去掉外边框 --- .../app/src/components/WindowChrome.tsx | 12 +++++-- .../app/src/pages/LocalAsr/components.tsx | 23 ++++++++----- openless-all/app/src/pages/LocalAsr/index.tsx | 34 ++++++++++++++++--- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 30b089663..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 (
diff --git a/openless-all/app/src/pages/LocalAsr/components.tsx b/openless-all/app/src/pages/LocalAsr/components.tsx index 0330548e1..6bd16b955 100644 --- a/openless-all/app/src/pages/LocalAsr/components.tsx +++ b/openless-all/app/src/pages/LocalAsr/components.tsx @@ -1066,6 +1066,9 @@ export function DownloadDialog({ animation: "ol-modal-backdrop-in 0.18s var(--ol-motion-soft)", }} onClick={(e) => { + // busy = 真实下载中(index 传 anyDownloadInFlight):下载中点击 + // 遮罩不关闭——否则用户点遮罩下的设置项时弹窗会「像按了叉一样 + // 消失」,误以为是设置页闪退。只能走右上角 ✕ 关闭。 if (e.target === e.currentTarget && !busy) onClose() }} > @@ -1141,17 +1144,19 @@ export function DownloadDialog({
- {/* 左侧:模型选择(竖排,结构与设置页侧栏一致) */} + {/* 左侧:模型选择(竖排)——与设置页左栏 rail 同风格, + 宽度对齐设置页(200px),让弹窗看起来和设置页是一体的 */}
{t("localAsr.sidebarTitle")} @@ -1173,9 +1178,9 @@ 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 === resolvedId ? "var(--ol-segmented-active-bg)" @@ -1190,7 +1195,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)", }} > { + if (!downloadDialogOpen || !settings) return + for (const entry of allSidebarEntries) { + if (!entry.repo) continue + void ensureHfCard(entry.id, settings.mirror) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [downloadDialogOpen, settings?.mirror]) + + // 选中模型变化时补一次:缓存命中立即返回(零开销),失败的条目在此重试。 useEffect(() => { if (!downloadDialogOpen || !selectedModelId || !settings) return const entry = allSidebarEntries.find((e) => e.id === selectedModelId) @@ -1516,6 +1527,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 || @@ -1977,7 +1999,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 From fb17138166894041aadfcf10625e832a60074389 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 6 Aug 2026 19:31:57 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(local-asr):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E9=87=8D=E5=86=99=E2=80=94=E2=80=94=E5=8E=BB?= =?UTF-8?q?=E5=85=A5=E5=9C=BA=E5=8A=A8=E7=94=BB=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E9=A2=84=E5=8A=A0=E8=BD=BD=EF=BC=88WKWebView=20=E9=97=AA?= =?UTF-8?q?=E7=83=81=E6=A0=B9=E5=9B=A0=EF=BC=89=EF=BC=8C=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=94=B9=E3=80=8C=E4=B8=8B=E8=BD=BD=E6=A8=A1=E5=9E=8B=E3=80=8D?= =?UTF-8?q?=EF=BC=8CREADME=20=E7=AE=80=E4=BB=8B=E6=8F=90=E5=8F=96=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src-tauri/src/asr/local/download.rs | 140 ++++++++++++++++-- openless-all/app/src/i18n/en.ts | 3 +- openless-all/app/src/i18n/ja.ts | 3 +- openless-all/app/src/i18n/ko.ts | 3 +- openless-all/app/src/i18n/zh-CN.ts | 3 +- openless-all/app/src/i18n/zh-TW.ts | 3 +- .../app/src/pages/LocalAsr/components.tsx | 17 ++- openless-all/app/src/pages/LocalAsr/index.tsx | 48 +++--- 8 files changed, 179 insertions(+), 41 deletions(-) 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 7cda80c9c..a5c8a2019 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -248,15 +248,17 @@ async fn fetch_readme_first_paragraph( 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<&str> = Vec::new(); + let mut parts: Vec = Vec::new(); for raw_line in block.lines() { let line = raw_line.trim(); if line.is_empty() @@ -264,10 +266,15 @@ pub(crate) fn first_readme_paragraph(markdown: &str) -> String { || line.starts_with('!') || line.starts_with('|') || line.starts_with("---") + || line.starts_with('<') + || is_link_only_line(line) { continue; } - parts.push(line); + let stripped = strip_markdown_inline(line); + if !stripped.is_empty() { + parts.push(stripped); + } } if parts.is_empty() { continue; @@ -277,6 +284,75 @@ pub(crate) fn first_readme_paragraph(markdown: &str) -> String { 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(); @@ -548,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); @@ -1201,8 +1275,9 @@ fn emit_cancelled( #[cfg(test)] mod tests { use super::{ - existing_file_is_complete, first_readme_paragraph, remove_partial_artifacts, - truncate_description, HF_CARD_DESC_MAX_CHARS, + 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] @@ -1277,6 +1352,53 @@ mod tests { 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"); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 0a4961fb9..1803a16e2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1482,13 +1482,14 @@ 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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 4b45b40f3..f8c439672 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1450,13 +1450,14 @@ export const ja: typeof zhCN = { downloading: 'ダウンロード中', startDownload: 'ダウンロード開始', downloadNewModel: '新しいモデルをダウンロード', - downloadDialogTitle: 'ローカル ASR モデルを選択', + downloadDialogTitle: 'モデルをダウンロード', downloadDialogAlreadyHave: 'ダウンロード済みです', downloadDialogDesc: '左のモデルを選択して「ダウンロード開始」をクリックします。完了後、「サービス → AI プロバイダー → ASR 文字起こし」でローカルモデルのプロバイダーを選択すると利用できます。', detailRepo: 'リポジトリ', hfDownloads: 'ダウンロード数', hfLikes: 'いいね', hfDescription: 'モデル紹介', + hfNoDescription: '紹介文はありません', hfCardFailed: 'モデル情報の取得に失敗しました', detailFiles: 'ファイル', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 3e6e5812a..1d26dfd9f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1450,13 +1450,14 @@ export const ko: typeof zhCN = { downloading: '다운로드 중', startDownload: '다운로드 시작', downloadNewModel: '새 모델 다운로드', - downloadDialogTitle: '로컬 ASR 모델 선택', + downloadDialogTitle: '모델 다운로드', downloadDialogAlreadyHave: '이미 다운로드됨 — 바로 사용 가능', downloadDialogDesc: '왼쪽에서 모델을 선택하고 「다운로드 시작」을 클릭하세요. 완료 후 「서비스 → AI 공급자 → ASR 음성 전사」에서 로컬 모델 공급자를 선택하면 사용할 수 있습니다.', detailRepo: '저장소', hfDownloads: '다운로드 수', hfLikes: '좋아요', hfDescription: '모델 소개', + hfNoDescription: '소개가 없습니다', hfCardFailed: '모델 정보를 불러오지 못했습니다', detailFiles: '개 파일', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 9066e753b..71d97e87c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1480,13 +1480,14 @@ export const zhCN = { downloading: '下载中', startDownload: '开始下载', downloadNewModel: '下载新模型', - downloadDialogTitle: '选择本地 ASR 模型', + downloadDialogTitle: '下载模型', downloadDialogAlreadyHave: '该模型已下载,可直接使用', downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。', detailRepo: '模型仓库', hfDownloads: '下载量', hfLikes: '收藏数', hfDescription: '模型简介', + hfNoDescription: '暂无简介', hfCardFailed: '模型信息获取失败', detailFiles: '个文件', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index ab2b6f20a..907c1a4f5 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1448,13 +1448,14 @@ export const zhTW: typeof zhCN = { downloading: '下載中', startDownload: '開始下載', downloadNewModel: '下載新模型', - downloadDialogTitle: '選擇本地 ASR 模型', + downloadDialogTitle: '下載模型', downloadDialogAlreadyHave: '該模型已下載,可直接使用', downloadDialogDesc: '選擇左側模型後點擊「開始下載」。下載完成後,到「服務 → AI 提供商 → ASR 語音轉寫」選擇本地模型供應商即可使用。', detailRepo: '模型倉庫', hfDownloads: '下載量', hfLikes: '收藏數', hfDescription: '模型簡介', + hfNoDescription: '暫無簡介', hfCardFailed: '模型資訊取得失敗', detailFiles: '個檔案', diff --git a/openless-all/app/src/pages/LocalAsr/components.tsx b/openless-all/app/src/pages/LocalAsr/components.tsx index 6bd16b955..412e456be 100644 --- a/openless-all/app/src/pages/LocalAsr/components.tsx +++ b/openless-all/app/src/pages/LocalAsr/components.tsx @@ -1063,7 +1063,10 @@ 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):下载中点击 @@ -1083,10 +1086,8 @@ export function DownloadDialog({ border: "0.5px solid var(--ol-line-strong)", boxShadow: "var(--ol-shadow-xl)", overflow: "hidden", - // 用非回弹曲线(--ol-motion-soft):spring 有 overshoot, - // 弹窗入场会上下弹跳;WKWebView 重放动画时更明显。 - animation: - "ol-modal-card-in 0.24s var(--ol-motion-soft)", + // 无入场动画,见上方遮罩注释:动画重放是「弹窗闪一下 / + // 上下动」的 WKWebView 合成层根源,去掉后纯静态出现。 }} > {/* 标题行:左标题 + 右 ✕ 关闭 */} @@ -1279,7 +1280,7 @@ export function DownloadDialog({ {t("localAsr.hfLikes")}: {formatCount(hfCard.card.likes)}
- {hfCard.card.description && ( + {hfCard.card.description ? ( <>
{t("localAsr.hfDescription")} @@ -1296,6 +1297,10 @@ export function DownloadDialog({ {hfCard.card.description}
+ ) : ( +
+ {t("localAsr.hfNoDescription")} +
)}
)} diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 611f9bbaa..aea079838 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -200,9 +200,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const [engineStatus, setEngineStatus] = useState(null) const refreshTimer = useRef(null) - // 弹窗打开期间停掉 3s 轮询:轮询会 setState 重排遮罩后的看板内容, - // 透过半透明遮罩看得到内容在跳(配合 WKWebView 重栅格化更明显)。 - const downloadDialogOpenRef = useRef(false) const foundryRefreshTimer = useRef(null) const sherpaRefreshTimer = useRef(null) const sherpaDownloadRefreshTimer = useRef(null) @@ -561,10 +558,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // 3s 轮询磁盘状态:模型被外部删除 / 下载中断时前端自动跟随(删除后 // 看板选中自动回落、下拉回到引擎级入口),不用等重开页面。qwen3 的 // list 是本地 fs walk,很轻;远端尺寸有缓存不会重复请求。 - // 下载弹窗打开时暂停——弹窗是静态目录选择,轮询的重渲染会让遮罩后 - // 的看板内容每 3s 跳动一次。 const pollTimer = window.setInterval(() => { - if (downloadDialogOpenRef.current) return void refresh() }, 3000) return () => { @@ -574,9 +568,16 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // 弹窗打开状态同步到 ref(上述 mount 闭包读不到最新 state)。 + // 下载弹窗打开期间暂停 3s 轮询:弹窗是静态目录选择,轮询 setState 会 + // 重排遮罩后的看板内容,透过半透明遮罩看得到内容在跳。弹窗关闭后轮询 + // 自动重启(依赖 downloadDialogOpen 的 effect 重建 interval)。 useEffect(() => { - downloadDialogOpenRef.current = downloadDialogOpen + 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 变更),前端零轮询。 @@ -629,19 +630,9 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings?.mirror]) - // 弹窗打开时预加载全部条目的 HF 模型卡片(macOS 两个 Qwen3 模型并行抓取, - // 毫秒级)。切换选项时右侧内容直接有数据——不做「加载中→内容」的替换, - // 切换闪烁也就不存在了。结果缓存;失败条目靠下方选中时补拉重试。 - useEffect(() => { - if (!downloadDialogOpen || !settings) return - for (const entry of allSidebarEntries) { - if (!entry.repo) continue - void ensureHfCard(entry.id, settings.mirror) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [downloadDialogOpen, settings?.mirror]) - - // 选中模型变化时补一次:缓存命中立即返回(零开销),失败的条目在此重试。 + // 选中模型变化时按需拉 HF 模型卡片(只请求当前选中项,不做全目录预加载 + // ——打开瞬间并行发多个网络请求 + setState 是 WKWebView 重栅格化闪烁的 + // 峰值源)。成功结果缓存,切换回已加载的模型零请求;失败条目在此重试。 useEffect(() => { if (!downloadDialogOpen || !selectedModelId || !settings) return const entry = allSidebarEntries.find((e) => e.id === selectedModelId) @@ -1853,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