From 3d34ddd80e735499fa4d6eb4d5dd9949fb41b965 Mon Sep 17 00:00:00 2001 From: chyuwei Date: Sat, 19 Sep 2026 22:34:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(model-store):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BB=8E=20ModelScope=20=E4=B8=8B=E8=BD=BD=20Qwen=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openless-core/src/local_asr_catalog.rs | 3 + .../crates/openless-core/src/model_store.rs | 318 +++++++++++++++++- .../crates/openless-core/src/shared_types.rs | 2 +- .../openless-core/tests/local_asr_contract.rs | 4 + openless-all/app/src/i18n/de.ts | 3 +- openless-all/app/src/i18n/en.ts | 3 +- openless-all/app/src/i18n/es.ts | 3 +- openless-all/app/src/i18n/fr.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 +- openless-all/app/src/lib/localAsr.ts | 4 +- openless-all/app/src/lib/types.ts | 2 +- openless-all/app/src/pages/LocalAsr/index.tsx | 64 +++- 15 files changed, 384 insertions(+), 37 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/local_asr_catalog.rs b/openless-all/app/crates/openless-core/src/local_asr_catalog.rs index 1a20e8819..ee90f913f 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_catalog.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_catalog.rs @@ -257,6 +257,7 @@ pub enum LocalAsrMirror { #[default] Huggingface, HfMirror, + Modelscope, GithubRelease, } @@ -264,6 +265,7 @@ impl LocalAsrMirror { pub fn from_legacy(value: &str) -> Self { match value.trim() { "hf-mirror" => Self::HfMirror, + "modelscope" => Self::Modelscope, "github-release" => Self::GithubRelease, _ => Self::Huggingface, } @@ -273,6 +275,7 @@ impl LocalAsrMirror { match self { Self::Huggingface => "huggingface", Self::HfMirror => "hf-mirror", + Self::Modelscope => "modelscope", Self::GithubRelease => "github-release", } } diff --git a/openless-all/app/crates/openless-core/src/model_store.rs b/openless-all/app/crates/openless-core/src/model_store.rs index ab61b1818..3795fd05b 100644 --- a/openless-all/app/crates/openless-core/src/model_store.rs +++ b/openless-all/app/crates/openless-core/src/model_store.rs @@ -27,6 +27,7 @@ pub const DEFAULT_MODEL_MAX_TOTAL_BYTES: u64 = 32 * 1024 * 1024 * 1024; pub const DEFAULT_MODEL_METADATA_BYTES: u64 = 8 * 1024 * 1024; pub const DEFAULT_MODEL_MAX_RETRIES: u8 = 4; const PARTIAL_INDEX_VERSION: u8 = 1; +const MODELSCOPE_BASE_URL: &str = "https://modelscope.cn"; pub fn model_mirror_base( mirror: crate::local_asr_catalog::LocalAsrMirror, @@ -34,6 +35,10 @@ pub fn model_mirror_base( match mirror { crate::local_asr_catalog::LocalAsrMirror::Huggingface => Ok("https://huggingface.co"), crate::local_asr_catalog::LocalAsrMirror::HfMirror => Ok("https://hf-mirror.com"), + crate::local_asr_catalog::LocalAsrMirror::Modelscope => Err(BackendError::new( + BackendErrorCode::Unsupported, + "ModelScope uses its own repository API", + )), crate::local_asr_catalog::LocalAsrMirror::GithubRelease => Err(BackendError::new( BackendErrorCode::Unsupported, "GitHub release models use their catalog URL rather than a Hugging Face mirror", @@ -184,6 +189,7 @@ pub struct ModelArchiveSpec { pub struct ModelCatalogEntry { pub target: LocalAsrTarget, pub repository: String, + pub modelscope_repository: Option, pub display_name: String, pub family: String, pub mode: String, @@ -241,6 +247,7 @@ impl ModelCatalog { let mut add = |runtime, id: &str, repository: &str, + modelscope_repository: Option<&str>, display_name: &str, family: &str, mode: &str, @@ -249,6 +256,7 @@ impl ModelCatalog { entries.push(ModelCatalogEntry { target: LocalAsrTarget::parse(runtime, id).expect("built-in model id"), repository: repository.into(), + modelscope_repository: modelscope_repository.map(str::to_string), display_name: display_name.into(), family: family.into(), mode: mode.into(), @@ -267,6 +275,7 @@ impl ModelCatalog { LocalAsrRuntime::Generic, id, repository, + Some(repository), id, "qwen3", "offline", @@ -286,6 +295,7 @@ impl ModelCatalog { LocalAsrRuntime::Generic, id, "ggerganov/whisper.cpp", + None, id, "whisper", "offline", @@ -304,6 +314,7 @@ impl ModelCatalog { LocalAsrRuntime::Foundry, id, "microsoft/whisper", + None, id, "whisper", "offline", @@ -387,6 +398,7 @@ impl ModelCatalog { LocalAsrRuntime::SherpaOnnx, id, repository, + None, display_name, family, mode, @@ -400,6 +412,7 @@ impl ModelCatalog { LocalAsrRuntime::SherpaOnnx, "qwen3-asr-0.6b-int8", "", + None, "Qwen3-ASR 0.6B INT8", qwen_sherpa .sherpa_family() @@ -444,6 +457,7 @@ impl Default for ModelCatalogEntry { Self { target, repository: "Qwen/Qwen3-ASR-0.6B".into(), + modelscope_repository: Some("Qwen/Qwen3-ASR-0.6B".into()), display_name: "qwen3-asr-0.6b".into(), family: "qwen3".into(), mode: "offline".into(), @@ -760,11 +774,7 @@ impl ModelStore { sha256: Some(sha256.clone()), }], ModelFileSelector::QwenRepository | ModelFileSelector::Exact(_) => self - .fetch_hf_manifest( - target.clone(), - &entry.repository, - model_mirror_base(mirror)?, - ) + .fetch_remote_manifest(target.clone(), entry, mirror, None) .await? .files .into_iter() @@ -807,6 +817,9 @@ impl ModelStore { likes: 0, description: entry.display_name.clone(), } + } else if mirror == crate::local_asr_catalog::LocalAsrMirror::Modelscope { + self.fetch_modelscope_model_card(target.model_id(), modelscope_repository(entry)?) + .await? } else { self.fetch_hf_model_card( target.model_id(), @@ -841,7 +854,7 @@ impl ModelStore { )); } let (cancelled, _active_guard) = self.begin_active_download(&target)?; - let manifest = match entry.selector { + let manifest = match entry.selector.clone() { ModelFileSelector::Native => unreachable!("native selector returned above"), ModelFileSelector::Archive { url, @@ -870,10 +883,10 @@ impl ModelStore { Ok(manifest) }), ModelFileSelector::QwenRepository | ModelFileSelector::Exact(_) => { - self.fetch_hf_manifest_with_cancel( + self.fetch_remote_manifest( target.clone(), - &entry.repository, - model_mirror_base(mirror)?, + &entry, + mirror, Some(Arc::clone(&cancelled)), ) .await @@ -897,6 +910,103 @@ impl ModelStore { self.download_registered(manifest, cancelled).await } + async fn fetch_remote_manifest( + &self, + target: LocalAsrTarget, + entry: &ModelCatalogEntry, + mirror: crate::local_asr_catalog::LocalAsrMirror, + cancelled: Option>, + ) -> Result { + if mirror == crate::local_asr_catalog::LocalAsrMirror::Modelscope { + self.fetch_modelscope_manifest_with_cancel( + target, + modelscope_repository(entry)?, + cancelled, + ) + .await + } else { + self.fetch_hf_manifest_with_cancel( + target, + &entry.repository, + model_mirror_base(mirror)?, + cancelled, + ) + .await + } + } + + async fn fetch_modelscope_manifest_with_cancel( + &self, + target: LocalAsrTarget, + repository: &str, + cancelled: Option>, + ) -> Result { + validate_model_id(target.model_id())?; + validate_repository(repository)?; + let entry = self + .catalog + .entries() + .iter() + .find(|entry| { + entry.target == target && entry.modelscope_repository.as_deref() == Some(repository) + }) + .ok_or_else(|| invalid("model is not present in the ModelScope catalog"))?; + if matches!( + entry.selector, + ModelFileSelector::Native | ModelFileSelector::Archive { .. } + ) { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "selected model is not downloaded from a ModelScope repository", + )); + } + let request = self.transport.request(ModelTransportRequest { + url: format!( + "{MODELSCOPE_BASE_URL}/api/v1/models/{repository}/repo/files?Revision=master&Recursive=True" + ), + range: None, + max_response_bytes: DEFAULT_MODEL_METADATA_BYTES, + }); + let response = if let Some(cancelled) = cancelled { + tokio::select! { + value = request => value?, + () = wait_until_cancelled(cancelled) => return Err(cancelled_error()), + } + } else { + request.await? + }; + if response.status != 200 { + return Err(BackendError::new( + BackendErrorCode::Provider, + format!( + "ModelScope manifest request returned HTTP {}", + response.status + ), + )); + } + let value: serde_json::Value = serde_json::from_slice(&response.bytes) + .map_err(|error| invalid(format!("invalid ModelScope manifest JSON: {error}")))?; + if value.get("Success").and_then(serde_json::Value::as_bool) != Some(true) { + return Err(BackendError::new( + BackendErrorCode::Provider, + value + .get("Message") + .and_then(serde_json::Value::as_str) + .unwrap_or("ModelScope manifest request failed"), + )); + } + let files = value + .pointer("/Data/Files") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| invalid("ModelScope manifest response is missing files"))?; + let files = parse_modelscope_files(entry, repository, files)?; + let manifest = ModelManifest::new(target, repository, files)?; + if manifest.total_bytes > self.config.max_total_bytes { + return Err(invalid("model exceeds the configured total size limit")); + } + Ok(manifest) + } + pub async fn fetch_hf_manifest( &self, target: LocalAsrTarget, @@ -988,6 +1098,63 @@ impl ModelStore { manifest_from_hf_pages(entry, &pages, base_url, self.config.max_total_bytes) } + async fn fetch_modelscope_model_card( + &self, + model_id: &str, + repository: &str, + ) -> Result { + validate_model_id(model_id)?; + validate_repository(repository)?; + let response = self + .transport + .request(ModelTransportRequest { + url: format!("{MODELSCOPE_BASE_URL}/openapi/v1/models/{repository}"), + range: None, + max_response_bytes: DEFAULT_MODEL_METADATA_BYTES, + }) + .await?; + if response.status != 200 { + return Err(BackendError::new( + BackendErrorCode::Provider, + format!( + "ModelScope model card request returned HTTP {}", + response.status + ), + )); + } + let value: serde_json::Value = serde_json::from_slice(&response.bytes) + .map_err(|error| invalid(format!("invalid ModelScope model card JSON: {error}")))?; + if value.get("success").and_then(serde_json::Value::as_bool) != Some(true) { + return Err(BackendError::new( + BackendErrorCode::Provider, + "ModelScope model card request failed", + )); + } + let data = value + .get("data") + .ok_or_else(|| invalid("ModelScope model card response is missing data"))?; + let description = data + .get("description") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .or_else(|| data.get("readme").and_then(serde_json::Value::as_str)) + .map(first_readme_paragraph) + .unwrap_or_default(); + Ok(ModelCard { + model_id: model_id.into(), + repository: repository.into(), + downloads: data + .get("downloads") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + likes: data + .get("likes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + description: truncate_description(&description), + }) + } + pub async fn fetch_hf_model_card( &self, model_id: &str, @@ -2264,7 +2431,7 @@ fn validate_range_response( } match response.status { 200 if start == 0 && received == total => Ok(()), - 206 if received == end - start + 1 => match &response.metadata.content_range { + 200 | 206 if received == end - start + 1 => match &response.metadata.content_range { Some(range) if range.start == start && range.end == end && range.total == total => { Ok(()) } @@ -2342,6 +2509,18 @@ struct HfLfs { size: u64, } +#[derive(Debug, Deserialize)] +struct ModelScopeFileEntry { + #[serde(rename = "Type")] + entry_type: String, + #[serde(rename = "Path")] + path: String, + #[serde(rename = "Size")] + size: u64, + #[serde(rename = "Sha256")] + sha256: String, +} + pub fn parse_hf_tree_page( repository: &str, model_id: &str, @@ -2485,6 +2664,77 @@ fn parse_hf_tree_page_for_entry( Ok(files) } +fn parse_modelscope_files( + catalog_entry: &ModelCatalogEntry, + repository: &str, + entries: &[serde_json::Value], +) -> Result, BackendError> { + validate_model_id(catalog_entry.target.model_id())?; + validate_repository(repository)?; + if catalog_entry.modelscope_repository.as_deref() != Some(repository) { + return Err(invalid("model is not present in the ModelScope catalog")); + } + let mut files = Vec::new(); + let mut seen = BTreeSet::new(); + for value in entries { + let entry: ModelScopeFileEntry = serde_json::from_value(value.clone()) + .map_err(|_| invalid("invalid ModelScope file entry"))?; + if entry.entry_type != "blob" { + continue; + } + validate_model_path(&entry.path)?; + let Some(local_path) = catalog_entry.selector.local_path(&entry.path) else { + continue; + }; + validate_model_path(&local_path)?; + if !seen.insert(local_path.clone()) { + return Err(invalid("duplicate selected file in ModelScope repository")); + } + if entry.size == 0 || entry.size > DEFAULT_MODEL_MAX_FILE_BYTES { + return Err(invalid( + "ModelScope file size is invalid or exceeds the configured limit", + )); + } + if entry.sha256.len() != 64 || !entry.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(invalid("invalid ModelScope file sha256")); + } + let mut url = url::Url::parse(&format!( + "{MODELSCOPE_BASE_URL}/api/v1/models/{repository}/repo" + )) + .map_err(|_| invalid("invalid ModelScope download URL"))?; + url.query_pairs_mut() + .append_pair("Revision", "master") + .append_pair("FilePath", &entry.path); + files.push(ModelFile { + path: local_path, + url: url.into(), + size_bytes: entry.size, + sha256: Some(entry.sha256.to_ascii_lowercase()), + }); + } + if files.is_empty() { + return Err(invalid( + "ModelScope repository returned no selected model files", + )); + } + if let ModelFileSelector::Exact(expected) = &catalog_entry.selector { + let actual = files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + if let Some(missing) = expected + .iter() + .find(|file| !actual.contains(file.local_path.as_str())) + { + return Err(invalid(format!( + "ModelScope repository is missing required model file {}", + missing.remote_path + ))); + } + } + Ok(files) +} + /// The Hugging Face tree API returns `lfs.oid` as a bare SHA-256 hex digest; /// the `sha256:`-prefixed form (as in Git LFS pointer files) is accepted too. fn parse_lfs_sha256(oid: &str) -> Result { @@ -2568,6 +2818,15 @@ fn validate_repository(repository: &str) -> Result<(), BackendError> { Ok(()) } +fn modelscope_repository(entry: &ModelCatalogEntry) -> Result<&str, BackendError> { + entry.modelscope_repository.as_deref().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "selected model has no official ModelScope repository", + ) + }) +} + fn expand_tar_bz2_archive( staging: &Path, spec: &ModelArchiveSpec, @@ -2965,6 +3224,38 @@ mod tests { ); } + #[test] + fn modelscope_uses_explicit_catalog_repository_and_checksum() { + let catalog = ModelCatalog::standard(); + let qwen = catalog + .find(LocalAsrRuntime::Generic, "qwen3-asr-0.6b") + .unwrap(); + let checksum = "a".repeat(64); + let files = parse_modelscope_files( + qwen, + "Qwen/Qwen3-ASR-0.6B", + &[ + serde_json::json!({"Type":"blob","Path":"config.json","Size":6193,"Sha256":checksum}), + serde_json::json!({"Type":"blob","Path":"README.md","Size":10,"Sha256":"b".repeat(64)}), + ], + ) + .unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "config.json"); + assert_eq!(files[0].sha256.as_deref(), Some(checksum.as_str())); + let url = url::Url::parse(&files[0].url).unwrap(); + assert_eq!(url.host_str(), Some("modelscope.cn")); + assert!(url + .query_pairs() + .any(|(key, value)| key == "FilePath" && value == "config.json")); + + assert!(catalog + .find(LocalAsrRuntime::Generic, "whisper-small") + .unwrap() + .modelscope_repository + .is_none()); + } + #[test] fn hf_link_pagination_accepts_same_origin_and_rejects_redirected_origin() { let current = "https://huggingface.co/api/models/org/model/tree/main?limit=1000"; @@ -2987,7 +3278,7 @@ mod tests { } #[test] - fn range_contract_accepts_complete_200_and_exact_206_only() { + fn range_contract_accepts_complete_or_exact_partial_responses() { let complete = ModelTransportResponse { status: 200, bytes: vec![0; 4], @@ -3012,6 +3303,11 @@ mod tests { }; assert!(validate_range_response(&partial, 2, 3, 4).is_ok()); assert!(validate_range_response(&partial, 0, 1, 4).is_err()); + let modelscope_partial = ModelTransportResponse { + status: 200, + ..partial + }; + assert!(validate_range_response(&modelscope_partial, 2, 3, 4).is_ok()); } #[tokio::test] diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 87c334826..97b73b9e7 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -538,7 +538,7 @@ pub struct UserPreferences { /// 设置页测试 Whisper 时覆盖 Qwen 的模型选择。 #[serde(default = "default_local_whisper_model")] pub local_whisper_active_model: String, - /// 本地模型下载源镜像("huggingface" / "hf-mirror")。 + /// 本地模型下载源("huggingface" / "hf-mirror" / "modelscope")。 #[serde(default = "default_local_asr_mirror")] pub local_asr_mirror: String, /// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放; diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 900b552db..0040d52c2 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -44,6 +44,10 @@ fn public_local_asr_preferences_keep_legacy_normalization_semantics() { LocalAsrMirror::from_legacy("hf-mirror"), LocalAsrMirror::HfMirror ); + assert_eq!( + LocalAsrMirror::from_legacy("modelscope"), + LocalAsrMirror::Modelscope + ); assert_eq!( LocalAsrMirror::from_legacy("unexpected"), LocalAsrMirror::Huggingface diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ea3628ad5..97f497230 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -2205,9 +2205,10 @@ export const de: typeof zhCN = { groupOther: 'Weitere', mirrorLabel: 'Download-Spiegelserver', mirrorDesc: - 'huggingface.co ist die offizielle Quelle. hf-mirror.com ist ein Community-Spiegelserver, der aus Festlandchina oft besser erreichbar ist.', + 'Wählen Sie HuggingFace, den Community-Spiegelserver oder offizielle ModelScope-Repositories für unterstützte Modelle.', mirrorHuggingface: 'Offizielles HuggingFace (huggingface.co)', mirrorHfMirror: 'Spiegelserver für Festlandchina (hf-mirror.com)', + mirrorModelscope: 'Offizielles ModelScope (modelscope.cn)', activeBadge: 'In Verwendung', downloadedBadge: 'Heruntergeladen', notDownloadedBadge: 'Nicht heruntergeladen', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index bf2d03b75..d99127588 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -2138,9 +2138,10 @@ export const en: typeof zhCN = { groupOther: 'Other', mirrorLabel: 'Download mirror', mirrorDesc: - 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', + 'Choose HuggingFace, the community mirror, or official ModelScope repositories for supported models.', mirrorHuggingface: 'HuggingFace official (huggingface.co)', mirrorHfMirror: 'Mainland mirror (hf-mirror.com)', + mirrorModelscope: 'ModelScope official (modelscope.cn)', activeBadge: 'In use', downloadedBadge: 'Downloaded', notDownloadedBadge: 'Not downloaded', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 0c71ec6f2..b71914ce4 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -2186,9 +2186,10 @@ export const es: typeof zhCN = { groupOther: 'Otros', mirrorLabel: 'Servidor de descarga', mirrorDesc: - 'huggingface.co es el origen oficial; hf-mirror.com es una réplica de la comunidad que suele funcionar mejor en China continental.', + 'Elige HuggingFace, la réplica comunitaria o los repositorios oficiales de ModelScope para modelos compatibles.', mirrorHuggingface: 'HuggingFace oficial (huggingface.co)', mirrorHfMirror: 'Réplica para China continental (hf-mirror.com)', + mirrorModelscope: 'ModelScope oficial (modelscope.cn)', activeBadge: 'En uso', downloadedBadge: 'Descargado', notDownloadedBadge: 'Sin descargar', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 692b1e9d2..4898c1cef 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -2215,9 +2215,10 @@ export const fr: typeof zhCN = { groupOther: 'Autres', mirrorLabel: 'Serveur de téléchargement', mirrorDesc: - 'huggingface.co est la source officielle ; hf-mirror.com est un miroir communautaire souvent plus accessible depuis la Chine continentale.', + 'Choisissez HuggingFace, le miroir communautaire ou les dépôts ModelScope officiels pour les modèles pris en charge.', mirrorHuggingface: 'HuggingFace officiel (huggingface.co)', mirrorHfMirror: 'Miroir pour la Chine continentale (hf-mirror.com)', + mirrorModelscope: 'ModelScope officiel (modelscope.cn)', activeBadge: 'En cours d’utilisation', downloadedBadge: 'Téléchargé', notDownloadedBadge: 'Non téléchargé', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 7e5440262..94f73f08a 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -2106,9 +2106,10 @@ export const ja: typeof zhCN = { groupOther: 'その他', mirrorLabel: 'ダウンロードミラー', mirrorDesc: - '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。', + 'HuggingFace、コミュニティミラー、対応モデルの ModelScope 公式リポジトリを選択できます。', mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)', mirrorHfMirror: '中国ミラー (hf-mirror.com)', + mirrorModelscope: 'ModelScope 公式 (modelscope.cn)', activeBadge: '使用中', downloadedBadge: 'ダウンロード済み', notDownloadedBadge: '未ダウンロード', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 6dc745a55..96b4a6230 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -2088,9 +2088,10 @@ export const ko: typeof zhCN = { groupOther: '기타', mirrorLabel: '다운로드 미러', mirrorDesc: - '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.', + 'HuggingFace, 커뮤니티 미러 또는 지원 모델의 공식 ModelScope 저장소를 선택합니다.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', mirrorHfMirror: '중국 미러 (hf-mirror.com)', + mirrorModelscope: 'ModelScope 공식 (modelscope.cn)', activeBadge: '사용 중', downloadedBadge: '다운로드됨', notDownloadedBadge: '다운로드되지 않음', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f503982eb..0ccbb6e18 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2018,9 +2018,10 @@ export const zhCN = { groupDownload: '下载与管理', groupOther: '其他', mirrorLabel: '下载镜像源', - mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', + mirrorDesc: '可切换 HuggingFace、国内社区镜像或受支持模型的 ModelScope 官方仓库。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', mirrorHfMirror: '国内镜像 (hf-mirror.com)', + mirrorModelscope: 'ModelScope 官方 (modelscope.cn)', activeBadge: '当前使用', downloadedBadge: '已下载', notDownloadedBadge: '未下载', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e2b3cff71..6422b4c6c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -2004,9 +2004,10 @@ export const zhTW: typeof zhCN = { groupDownload: '下載與管理', groupOther: '其他', mirrorLabel: '下載鏡像源', - mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', + mirrorDesc: '可切換 HuggingFace、社群鏡像或受支援模型的 ModelScope 官方倉庫。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', mirrorHfMirror: '國內鏡像 (hf-mirror.com)', + mirrorModelscope: 'ModelScope 官方 (modelscope.cn)', activeBadge: '當前使用', downloadedBadge: '已下載', notDownloadedBadge: '未下載', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index dc380c86e..034aa9358 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -4,7 +4,7 @@ // 事件:local-asr-download-progress / local-asr-token // // 注意:模型文件清单与尺寸不在此处硬编码 —— 通过 -// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。 +// `fetchLocalAsrRemoteInfo()` 实时从所选模型源拉取。 import { invokeOrMock } from './ipc'; import type { OS } from '../components/WindowChrome'; @@ -15,7 +15,7 @@ export function isLocalAsrModelSupportedOnOs(modelId: string, os: OS): boolean { return true; } -export type LocalAsrMirror = 'huggingface' | 'hf-mirror'; +export type LocalAsrMirror = 'huggingface' | 'hf-mirror' | 'modelscope'; export interface LocalAsrSettings { providerId: string; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 25ecc2cd2..eef022281 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -452,7 +452,7 @@ export interface UserPreferences { localAsrActiveModel: string; /** macOS 本地 Whisper 当前激活的模型 id。 */ localWhisperActiveModel: string; - /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ + /** 本地模型下载源('huggingface' / 'hf-mirror' / 'modelscope')。 */ localAsrMirror: string; /** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放; * 300 = 默认 5 分钟;86400 = 不自动释放(保持加载)。 */ diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 9cfc7a63e..c2cb9f1cc 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -3,7 +3,7 @@ // 功能: // - 顶部:当前激活模型 + 镜像源切换 // - 模型列表:每行模型 = 真实尺寸 / 进度 / [下载|取消|删除|设为默认] -// - 真实尺寸通过 fetchLocalAsrRemoteInfo 实时从 HuggingFace API 拉,**不硬编码** +// - 真实尺寸通过 fetchLocalAsrRemoteInfo 实时从所选模型源拉,**不硬编码** // - 监听 `local-asr-download-progress` 事件实时刷新进度 // - Win 端引擎不可用时禁用下载按钮,提示见 issue #256 @@ -65,6 +65,7 @@ import { type HfModelCard, type LocalAsrDownloadProgress, type LocalAsrEngineStatus, + type LocalAsrMirror, type LocalAsrModelStatus, type LocalAsrSettings, type LocalAsrTestResult, @@ -111,6 +112,15 @@ const OS = detectOS(); const IS_WINDOWS = OS === 'win'; const IS_QWEN_PLATFORM = OS === 'mac' || OS === 'linux'; +function effectiveModelMirror(modelId: string, mirror: string): LocalAsrMirror { + if (mirror === 'modelscope' && !modelId.startsWith('qwen3-asr-')) return 'huggingface'; + return mirror as LocalAsrMirror; +} + +function effectiveSherpaMirror(mirror: string): string { + return mirror === 'modelscope' ? 'huggingface' : mirror; +} + interface LocalAsrProps { /// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced); /// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由 @@ -468,13 +478,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { void refreshSherpaCatalog(); void refreshSherpaModelDir(selectedSherpaAlias); void Promise.all( - SHERPA_ONNX_ASR_MODELS.map((m) => ensureSherpaRemoteSize(m.alias, s.mirror)), + SHERPA_ONNX_ASR_MODELS.map((m) => + ensureSherpaRemoteSize(m.alias, effectiveSherpaMirror(s.mirror)), + ), ); } // 拉远端真实尺寸(每个模型一次,结果留缓存) void Promise.all( supportedModels.map(async (m) => { - await ensureRemoteSize(m.id, s.mirror); + await ensureRemoteSize(m.id, effectiveModelMirror(m.id, s.mirror)); }), ); } catch (e) { @@ -646,10 +658,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { if (!settings) return; setRemoteSizes({}); setSherpaRemoteSizes({}); - void Promise.all(models.map((m) => ensureRemoteSize(m.id, settings.mirror))); + setHfCards({}); + void Promise.all( + models.map((m) => ensureRemoteSize(m.id, effectiveModelMirror(m.id, settings.mirror))), + ); if (IS_WINDOWS) { void Promise.all( - SHERPA_ONNX_ASR_MODELS.map((m) => ensureSherpaRemoteSize(m.alias, settings.mirror)), + SHERPA_ONNX_ASR_MODELS.map((m) => + ensureSherpaRemoteSize(m.alias, effectiveSherpaMirror(settings.mirror)), + ), ); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -662,7 +679,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { if (!downloadDialogOpen || !selectedModelId || !settings) return; const entry = allSidebarEntries.find((e) => e.id === selectedModelId); if (!entry?.repo) return; - void ensureHfCard(selectedModelId, settings.mirror); + void ensureHfCard( + selectedModelId, + effectiveModelMirror(selectedModelId, settings.mirror), + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [downloadDialogOpen, selectedModelId, settings?.mirror]); @@ -1250,7 +1270,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { })); try { setError(null); - await downloadSherpaOnnxAsrModel(modelAlias, settings?.mirror); + await downloadSherpaOnnxAsrModel( + modelAlias, + effectiveSherpaMirror(settings?.mirror ?? 'huggingface'), + ); await activateSherpaProvider(modelAlias); } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -1306,7 +1329,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { }, })); try { - await downloadLocalAsrModel(modelId, settings?.mirror); + await downloadLocalAsrModel( + modelId, + effectiveModelMirror(modelId, settings?.mirror ?? 'huggingface'), + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setProgress((prev) => { @@ -1530,7 +1556,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const selectedSherpaUsesReleaseArchive = selectedSherpaAlias === 'qwen3-asr-0.6b-int8'; const selectedSherpaMirrorValue = selectedSherpaUsesReleaseArchive ? 'github-release' - : (settings?.mirror ?? 'huggingface'); + : effectiveSherpaMirror(settings?.mirror ?? 'huggingface'); const selectedSherpaCatalog = sherpaCatalog.find((model) => model.alias === selectedSherpaAlias); const selectedSherpaDisplayName = selectedSherpaCatalog?.displayName ?? t(selectedSherpaModel.labelKey); @@ -1956,11 +1982,12 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { fileCount={selectedEntryRemote?.fileCount ?? null} mirrorLabel={ selectedEntry?.engine === 'qwen3' || selectedEntry?.engine === 'whisper' - ? settings?.mirror === 'hf-mirror' - ? 'hf-mirror' - : 'huggingface' + ? effectiveModelMirror( + selectedEntry.id, + settings?.mirror ?? 'huggingface', + ) : selectedEntry?.engine === 'sherpa' - ? (settings?.mirror ?? 'huggingface') + ? effectiveSherpaMirror(settings?.mirror ?? 'huggingface') : undefined } progress={selectedEntryProgress} @@ -2091,6 +2118,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { value: 'hf-mirror', label: t('localAsr.mirrorHfMirror'), }, + { + value: 'modelscope', + label: t('localAsr.mirrorModelscope'), + }, ]} style={{ fontSize: 13, height: 31, minWidth: 200 }} /> @@ -2320,7 +2351,12 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { catalogReloadRequestedRef.current = true; setDownloadDialog(false); }} - onRetryCard={(id) => void ensureHfCard(id, settings?.mirror ?? 'huggingface')} + onRetryCard={(id) => + void ensureHfCard( + id, + effectiveModelMirror(id, settings?.mirror ?? 'huggingface'), + ) + } hfCardOf={(id) => { const state = hfCards[id]; if (!state) return null;