From 6ebf1f1ad173f6a7c55f289903e6fb76f48aee87 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 26 Sep 2026 00:22:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(omni):=20=E4=B8=BA=E7=99=BE=E7=82=BC?= =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E6=80=81=E9=9F=B3=E9=A2=91=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20data=20URL=20=E5=B9=B6=E9=99=90=E5=AE=9A=E4=B8=BB=E6=9C=BA?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/local-builds/2026-09-25-beta3.md | 5 ++ .../app/crates/openless-core/src/omni.rs | 87 ++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/local-builds/2026-09-25-beta3.md b/docs/local-builds/2026-09-25-beta3.md index c01335b4..9c6608ff 100644 --- a/docs/local-builds/2026-09-25-beta3.md +++ b/docs/local-builds/2026-09-25-beta3.md @@ -11,6 +11,7 @@ | #1102 | 纯修饰键组合听写快捷键 | 纳入 Core 仲裁窗口,防止普通组合键启动录音;录制时保留左右同类修饰键 | | #1104 | 可选 Requesty 润色渠道 | 保留三种请求格式、八语种和描述符一致性;排除 Linux locale 文件 | | #1110 | 远程输入停止/取消后隔离过期音频准备回调 | 保留麦克风复用,验证迟到回调和超时 | +| 本地 Omni 修复 | 百炼/DashScope 官方与专属端点的音频改用 WAV data URL | 标准 URL 解析仅匹配真实主机名;其他兼容端点保留裸 Base64,避免用户名、查询参数或片段误匹配 | 所有新增 Linux egui 文件和 Linux 发布工作流均与 Beta 2 相同,本次不构建或发布 Linux 安装包。Linux 既有独立 CI 失败不通过修改或绕过测试处理。 @@ -25,3 +26,7 @@ 整合树已通过前端生产构建及 93 个测试入口、Core 1041 项测试(1 项既有忽略),macOS Host 493 项及独立后端 12 项通过,并新增纯修饰键仲裁、左右键录制、APK 损坏/异常 ABI 和签名配置缓存回归。完整云端 CI、桌面/Android 构建与签名资产验收以发布记录为准。 本次继承 Beta 2 的平台设备验收范围;自动化测试和构建不代表新增 Windows、Intel Mac 或 Android 真机交互证据。性能比较同时记录基线、缓存状态、完整 job 与 Rust/Gradle 构建步骤耗时,见工作区 `outputs/beta3/2026-09-25/` 中的运行记录与发布验收结果。 + +### Omni 本地候选补充验证 + +2026-09-26 纳入额外的 Omni 音频格式修复,版本和构建号保持不变。补丁经过两轮独立审查;整合后的 Core 1045 项、macOS Host 493 项测试通过,Core 全目标严格 Clippy、单文件格式和差异检查通过。设置中的渠道验证仍只发送文本,实际音频链路需要用候选包完成一次真实录音验证,不能用文本验证成功代替。 diff --git a/openless-all/app/crates/openless-core/src/omni.rs b/openless-all/app/crates/openless-core/src/omni.rs index 1f39c07a..3f08aa46 100644 --- a/openless-all/app/crates/openless-core/src/omni.rs +++ b/openless-all/app/crates/openless-core/src/omni.rs @@ -40,6 +40,17 @@ impl OmniConfig { self.provider_id.trim() == OMNI_GEMINI_PROVIDER_ID || self.base_url.contains("generativelanguage.googleapis.com") } + + /// 百炼/DashScope 兼容端点把 `input_audio.data` 按 URL/data-URL 解析,裸 Base64 + /// 会被 400 拒绝("The provided URL does not appear to be valid")。与 + /// `asr::dashscope_multimodal` 转写通道同款,Base64 须带 data-URL 前缀; + /// 沿用 polish 的主机名关键词,但只检查 URL 解析后的真实 host。 + fn audio_requires_data_url(&self) -> bool { + reqwest::Url::parse(self.base_url.trim()) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|host| host.contains("dashscope") || host.contains("aliyuncs")) + } } /// 一次 Omni 调用的构建时快照(provider id + model),落历史归因用。 @@ -103,7 +114,13 @@ impl OpenAICompatibleOmni { ) -> Vec { let user_content = match wav_bytes { Some(wav) => { - let data = base64::engine::general_purpose::STANDARD.encode(wav); + let encoded = base64::engine::general_purpose::STANDARD.encode(wav); + // 百炼系端点要求 data-URL 前缀;OpenAI 官方等其他兼容端点保持裸 Base64。 + let data = if self.config.audio_requires_data_url() { + format!("data:audio/wav;base64,{encoded}") + } else { + encoded + }; let mut parts = vec![json!({ "type": "input_audio", "input_audio": { "data": data, "format": "wav" }, @@ -458,6 +475,74 @@ mod tests { assert_eq!(parts[1]["text"], "翻译成中文"); } + #[test] + fn build_messages_wraps_audio_as_data_url_for_dashscope() { + let mut dashscope = config(); + dashscope.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into(); + let provider = OpenAICompatibleOmni::new(dashscope); + let messages = provider.build_messages("system-prompt", "", Some(&[1u8, 2, 3, 4])); + let parts = messages[1]["content"].as_array().expect("audio parts"); + let data = parts[0]["input_audio"]["data"] + .as_str() + .expect("audio data"); + let payload = data + .strip_prefix("data:audio/wav;base64,") + .expect("data-url prefix for DashScope"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload) + .expect("valid base64"); + assert_eq!(decoded, vec![1u8, 2, 3, 4]); + } + + #[test] + fn build_messages_wraps_audio_as_data_url_for_bailian_dedicated_endpoint() { + let mut maas = config(); + maas.base_url = + "https://llm-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1".into(); + let provider = OpenAICompatibleOmni::new(maas); + let messages = provider.build_messages("system-prompt", "", Some(&[1u8, 2, 3, 4])); + let parts = messages[1]["content"].as_array().expect("audio parts"); + assert!(parts[0]["input_audio"]["data"] + .as_str() + .expect("audio data") + .starts_with("data:audio/wav;base64,")); + } + + #[test] + fn omni_audio_data_url_matches_dashscope_hosts_only() { + assert!(!config().audio_requires_data_url()); + let mut dashscope = config(); + dashscope.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into(); + assert!(dashscope.audio_requires_data_url()); + let mut maas = config(); + maas.base_url = "https://LLM-EXAMPLE.cn-beijing.maas.aliyuncs.com/v1".into(); + assert!(maas.audio_requires_data_url()); + // host 之外的关键字(路径里碰巧含 dashscope)不触发。 + let mut path_only = config(); + path_only.base_url = "https://example.com/proxy/dashscope/v1".into(); + assert!(!path_only.audio_requires_data_url()); + let mut empty = config(); + empty.base_url = String::new(); + assert!(!empty.audio_requires_data_url()); + } + + #[test] + fn omni_audio_data_url_ignores_non_host_url_components() { + for base_url in [ + "https://dashscope@api.example.com/v1", + "https://user:aliyuncs@api.example.com/v1", + "https://api.example.com?provider=dashscope", + "https://api.example.com#aliyuncs", + ] { + let mut other = config(); + other.base_url = base_url.into(); + assert!(!other.audio_requires_data_url(), "{base_url}"); + let provider = OpenAICompatibleOmni::new(other); + let messages = provider.build_messages("system", "", Some(&[1u8, 2, 3, 4])); + assert_eq!(messages[1]["content"][0]["input_audio"]["data"], "AQIDBA=="); + } + } + #[test] fn omni_body_has_stream_model_and_temperature() { let provider = OpenAICompatibleOmni::new(config());