diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index e14735af7..3f60abb91 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -47,6 +47,43 @@ pub async fn run_test(model_id: ModelId) -> Result { anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display()); } + // ── 模型文件完整性检查 ──────────────────────────────────────────── + // 在调 C FFI 之前先检查关键文件是否齐全、尺寸是否合理,避免因下载不完整 + // 或文件损坏导致 C 端 qwen_load / qwen_transcribe_audio segfault 杀死进程。 + // 需要的文件清单见 QwenAsrEngine::load() 注释。 + let required_files = ["config.json", "vocab.json", "merges.txt"]; + for fname in &required_files { + let path = dir.join(fname); + if !path.exists() { + anyhow::bail!("模型文件缺失:{fname},请重新下载(预期路径:{})", path.display()); + } + let meta = std::fs::metadata(&path) + .map_err(|e| anyhow::anyhow!("读取 {fname} 元数据失败:{e}"))?; + if meta.len() == 0 { + anyhow::bail!("模型文件为空:{fname},请重新下载"); + } + } + // safetensors 可能是单文件 model.safetensors 或分片 model-00001-of-NNNN.safetensors + let has_safetensors: Vec<_> = std::fs::read_dir(&dir) + .map_err(|e| anyhow::anyhow!("读取模型目录失败:{e}"))? + .filter_map(|entry| entry.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "safetensors")) + .collect(); + if has_safetensors.is_empty() { + anyhow::bail!("模型目录中没有 .safetensors 权重文件,请重新下载"); + } + for entry in &has_safetensors { + let meta = std::fs::metadata(entry.path()) + .map_err(|e| anyhow::anyhow!("读取 {} 元数据失败:{e}", entry.path().display()))?; + if meta.len() < 1024 { + anyhow::bail!( + "权重文件太小({} bytes):{},请重新下载", + meta.len(), + entry.path().display() + ); + } + } + let samples = decode_wav_16k_mono(TEST_WAV)?; let audio_ms = (samples.len() as u64) * 1000 / 16_000; diff --git a/openless-all/app/src-tauri/src/coordinator/llm_pipeline.rs b/openless-all/app/src-tauri/src/coordinator/llm_pipeline.rs index f4232842d..cc589c169 100644 --- a/openless-all/app/src-tauri/src/coordinator/llm_pipeline.rs +++ b/openless-all/app/src-tauri/src/coordinator/llm_pipeline.rs @@ -135,12 +135,19 @@ where ); return StreamingPolishOutcome::UnsupportedFallback; } - let provider = match build_active_llm_provider(llm_thinking_enabled) { - Ok(p) => p, - Err(e) => { + let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_active_llm_provider(llm_thinking_enabled) + })) { + Ok(Ok(p)) => p, + Ok(Err(e)) => { log::error!("[coord] streaming polish: build provider failed: {e}"); return StreamingPolishOutcome::Failed(e.to_string()); } + Err(panic) => { + let msg = format!("build_active_llm_provider panicked: {:?}", panic); + log::error!("[coord] {msg}"); + return StreamingPolishOutcome::Failed(msg); + } }; if !provider.supports_streaming_polish() { log::info!( @@ -259,7 +266,17 @@ pub(crate) async fn polish_text( .await?); } - let provider = build_active_llm_provider(llm_thinking_enabled)?; + let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_active_llm_provider(llm_thinking_enabled) + })) { + Ok(Ok(p)) => p, + Ok(Err(e)) => return Err(e), + Err(panic) => { + let msg = format!("build_active_llm_provider panicked: {:?}", panic); + log::error!("[coord] {msg}"); + anyhow::bail!(msg); + } + }; Ok(provider .polish( raw, @@ -305,7 +322,17 @@ pub(crate) async fn translate_text( .await?); } - let provider = build_active_llm_provider(llm_thinking_enabled)?; + let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_active_llm_provider(llm_thinking_enabled) + })) { + Ok(Ok(p)) => p, + Ok(Err(e)) => return Err(e), + Err(panic) => { + let msg = format!("build_active_llm_provider panicked: {:?}", panic); + log::error!("[coord] {msg}"); + anyhow::bail!(msg); + } + }; Ok(provider .translate_to( raw, @@ -597,7 +624,17 @@ where .await?); } - let provider = build_active_llm_provider(llm_thinking_enabled)?; + let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_active_llm_provider(llm_thinking_enabled) + })) { + Ok(Ok(p)) => p, + Ok(Err(e)) => return Err(e), + Err(panic) => { + let msg = format!("build_active_llm_provider panicked: {:?}", panic); + log::error!("[coord] {msg}"); + anyhow::bail!(msg); + } + }; Ok(provider .answer_chat_streaming( messages, @@ -687,8 +724,17 @@ pub(crate) fn resolve_ark_endpoint_with_policy( if api_key.trim().is_empty() && endpoint.is_none() { anyhow::bail!("API Key 为空"); } - let resolved = endpoint + let mut resolved = endpoint .unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string()); + // 兜底:用户可能在自定义 endpoint 时只写了 "192.168.1.100:8080/v1" 漏了 scheme, + // 被 reqwest::IntoUrl 自动补 https:// 后对纯 HTTP 的 llama.cpp 发请求会 TLS 握手失败。 + // 检测到无 scheme 时自动补 http://(局域网自托管服务最常见的形态)。 + // 用 to_ascii_lowercase 防用户输大写如 HTTP://... 时被误判为缺 scheme。 + let lower = resolved.to_ascii_lowercase(); + if !lower.starts_with("http://") && !lower.starts_with("https://") { + resolved = format!("http://{resolved}"); + log::info!("[llm] endpoint missing scheme, auto-prepended http://"); + } // issue #609 F-01(SSRF):用户自定义 endpoint 是 attacker-controlled,直接拿来发 // 带 API Key 的请求等于把凭据指哪打哪。这里对 host/IP 段 + scheme 做配置时校验, // 默认官方 endpoint 也过一遍(它本就合法)。注意这只防住"配置即字面内网地址", diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 488a4b0b3..923197be5 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1252,9 +1252,16 @@ async fn send_with_transient_retry( request: reqwest::RequestBuilder, ) -> Result { const RETRY_DELAY_MS: u64 = 500; - let initial = request - .try_clone() - .expect("memory-backed body (json/form) must be clonable for retry"); + let Some(initial) = request.try_clone() else { + // try_clone 失败(如 stream body 不可 clone)→ 不走重试,直接 send 一次。 + // 用 expect 会 panic 杀死整个进程,这里兜底为单次发送。 + log::warn!("[llm] request body not clonable, skipping retry"); + return match request.send().await { + Ok(r) => Ok(r), + Err(e) if e.is_timeout() => Err(LLMError::Timeout), + Err(e) => Err(LLMError::Network(e.to_string())), + }; + }; match initial.send().await { Ok(r) => Ok(r), Err(e) if e.is_connect() || e.is_request() => {