From b1293a5c64ce51768ef78f889ae558fa92b25742 Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Tue, 1 Sep 2026 10:33:42 +0800 Subject: [PATCH] feat(providers): add OrcaRouter model catalog integration --- README.md | 4 +- README.zh.md | 4 +- openless-all/app/src-tauri/src/asr/mimo.rs | 93 +++++-- .../app/src-tauri/src/commands/credentials.rs | 1 + .../app/src-tauri/src/commands/mod.rs | 65 ++++- .../app/src-tauri/src/commands/providers.rs | 127 +++++++++- openless-all/app/src-tauri/src/coordinator.rs | 25 +- .../src-tauri/src/coordinator/asr_wiring.rs | 13 +- .../src-tauri/src/coordinator/dictation.rs | 8 +- openless-all/app/src-tauri/src/polish.rs | 5 + .../app/src/components/ui/SelectLite.tsx | 207 +++++++++++---- 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 + .../app/src/lib/ipc/asr-credentials.ts | 55 +++- openless-all/app/src/lib/ipc/channels.ts | 66 ++++- openless-all/app/src/pages/LocalAsr/index.tsx | 3 +- openless-all/app/src/pages/Overview.tsx | 1 + .../src/pages/settings/ChannelList.test.ts | 16 +- .../app/src/pages/settings/ChannelList.tsx | 37 ++- .../pages/settings/ProvidersSection.test.ts | 46 +++- .../src/pages/settings/ProvidersSection.tsx | 239 +++++++++++++++++- .../app/src/pages/settings/shared.tsx | 3 + openless-all/app/vite.config.ts | 9 + 26 files changed, 946 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 1976a4737..551f3289b 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,9 @@ Every item below is one more layer sedimented into a default — a capability yo - 🎨 **Style Pack Marketplace** — browse, install, and like community **style packs** from the in-app Marketplace, and publish your own (custom system prompt per pack, switchable by hotkey). Backed by a moderated marketplace backend; uploads are reviewed before they go public. - ⚡ **Streaming insertion** — polished text is written to the cursor character by character to reduce perceived latency, with an automatic one-shot-paste fallback. Toggle in Settings → Recording. - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. -- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). +- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, OrcaRouter audio-input Gemini, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index 49af001c0..d337ada92 100644 --- a/README.zh.md +++ b/README.zh.md @@ -202,9 +202,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - 🎨 **风格包市场**——在应用内的 Marketplace 浏览、安装、点赞社区**风格包**,并发布自己的(每个包一套自定义系统提示词,可用快捷键切换)。由经过审核的市场后端支撑;上传内容公开前会经过审核。 - ⚡ **流式插入**——润色后的文本逐字符写入光标以降低感知延迟,并带有自动的一次性粘贴回退。可在 设置 → 录音 中切换。 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 -- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 +- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、OrcaRouter 音频输入 Gemini、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/src-tauri/src/asr/mimo.rs b/openless-all/app/src-tauri/src/asr/mimo.rs index 9b9567ee7..b93e8dd33 100644 --- a/openless-all/app/src-tauri/src/asr/mimo.rs +++ b/openless-all/app/src-tauri/src/asr/mimo.rs @@ -20,11 +20,19 @@ const MIMO_MAX_CHUNK_DURATION_MS: u64 = 180_000; pub const PROVIDER_ID: &str = "xiaomi-mimo-asr"; pub const DEFAULT_ENDPOINT: &str = "https://api.xiaomimimo.com/v1"; pub const DEFAULT_MODEL: &str = "mimo-v2.5-asr"; +pub const ORCAROUTER_PROVIDER_ID: &str = "orcarouter"; +pub const ORCAROUTER_DEFAULT_ENDPOINT: &str = "https://api.orcarouter.ai/v1"; +pub const ORCAROUTER_DEFAULT_MODEL: &str = "google/gemini-2.5-flash"; +const ORCAROUTER_TRANSCRIPTION_PROMPT: &str = + "Transcribe this audio verbatim. Return only the transcript, without commentary."; pub struct MimoBatchASR { api_key: String, base_url: String, model: String, + provider_name: &'static str, + prompt: Option<&'static str>, + audio_data_url: bool, buffer: Mutex>, } @@ -34,6 +42,21 @@ impl MimoBatchASR { api_key, base_url, model, + provider_name: "MiMo", + prompt: None, + audio_data_url: true, + buffer: Mutex::new(Vec::new()), + } + } + + pub fn new_orcarouter(api_key: String, base_url: String, model: String) -> Self { + Self { + api_key, + base_url, + model, + provider_name: "OrcaRouter", + prompt: Some(ORCAROUTER_TRANSCRIPTION_PROMPT), + audio_data_url: false, buffer: Mutex::new(Vec::new()), } } @@ -56,7 +79,7 @@ impl MimoBatchASR { async fn transcribe_inner(&self, pcm: &[u8]) -> Result { if self.api_key.trim().is_empty() { - anyhow::bail!("MiMo API key missing"); + anyhow::bail!("{} API key missing", self.provider_name); } let duration_ms = pcm_duration_ms(pcm); @@ -78,7 +101,7 @@ impl MimoBatchASR { .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) .collect(); let wav = encode_wav_16k_mono(&samples); - let body = mimo_chat_body(&self.model, &wav); + let body = audio_input_chat_body(&self.model, &wav, self.prompt, self.audio_data_url); let url = mimo_chat_completions_url(&self.base_url)?; let client = crate::net::http(); let resp = client @@ -87,15 +110,18 @@ impl MimoBatchASR { .json(&body) .send() .await - .context("MiMo ASR HTTP request failed")?; + .with_context(|| format!("{} ASR HTTP request failed", self.provider_name))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("MiMo ASR API error {}: {}", status, body); + anyhow::bail!("{} ASR API error {}: {}", self.provider_name, status, body); } - let json: Value = resp.json().await.context("parse MiMo ASR response")?; + let json: Value = resp + .json() + .await + .with_context(|| format!("parse {} ASR response", self.provider_name))?; Ok(extract_mimo_text(&json).trim().to_string()) } @@ -126,22 +152,42 @@ pub fn mimo_chat_completions_url(base_url: &str) -> Result { } pub fn mimo_chat_body(model: &str, wav: &[u8]) -> Value { - let audio_data = format!( - "data:audio/wav;base64,{}", - base64::engine::general_purpose::STANDARD.encode(wav) - ); + audio_input_chat_body(model, wav, None, true) +} + +pub fn orcarouter_chat_body(model: &str, wav: &[u8]) -> Value { + audio_input_chat_body(model, wav, Some(ORCAROUTER_TRANSCRIPTION_PROMPT), false) +} + +fn audio_input_chat_body( + model: &str, + wav: &[u8], + prompt: Option<&str>, + audio_data_url: bool, +) -> Value { + let encoded = base64::engine::general_purpose::STANDARD.encode(wav); + let audio_data = if audio_data_url { + format!("data:audio/wav;base64,{encoded}") + } else { + encoded + }; + let mut content = Vec::with_capacity(if prompt.is_some() { 2 } else { 1 }); + if let Some(text) = prompt { + content.push(serde_json::json!({ "type": "text", "text": text })); + } + content.push(serde_json::json!({ + "type": "input_audio", + "input_audio": { + "data": audio_data, + "format": "wav", + }, + })); serde_json::json!({ "model": model, "stream": false, "messages": [{ "role": "user", - "content": [{ - "type": "input_audio", - "input_audio": { - "data": audio_data, - "format": "wav", - }, - }], + "content": content, }], }) } @@ -296,6 +342,21 @@ mod tests { .starts_with("data:audio/wav;base64,")); } + #[test] + fn orcarouter_body_uses_prompt_and_raw_base64_audio() { + let body = orcarouter_chat_body(ORCAROUTER_DEFAULT_MODEL, b"wav"); + assert_eq!(body["model"], ORCAROUTER_DEFAULT_MODEL); + assert_eq!(body["messages"][0]["content"][0]["type"], "text"); + assert_eq!( + body["messages"][0]["content"][0]["text"], + ORCAROUTER_TRANSCRIPTION_PROMPT + ); + let audio = &body["messages"][0]["content"][1]; + assert_eq!(audio["type"], "input_audio"); + assert_eq!(audio["input_audio"]["format"], "wav"); + assert_eq!(audio["input_audio"]["data"], "d2F2"); + } + #[test] fn mimo_response_extracts_message_content() { let json = serde_json::json!({ diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 3a5356887..a5c8cce8d 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -148,6 +148,7 @@ fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> { "mimo" => Some("https://api.xiaomimimo.com/v1"), "cometapi" => Some("https://api.cometapi.com/v1"), "openrouterFree" => Some("https://openrouter.ai/api/v1"), + "orcarouter" => Some("https://api.orcarouter.ai/v1"), "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index e654d8fa0..244596b5f 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -251,7 +251,9 @@ mod tests { }; use crate::commands::providers::{ active_asr_is_keyless_for_validation, asr_transcriptions_url, fetch_provider_models, - is_gemini_base_url, models_url, parse_gemini_model_ids, parse_model_ids, ProviderConfig, + is_gemini_base_url, is_orcarouter_base_url, models_url, parse_gemini_model_ids, + parse_model_ids, parse_orcarouter_asr_model_ids, parse_orcarouter_model_ids, + ProviderConfig, }; use crate::commands::settings::{ parse_latest_beta_from_atom, persist_settings, SettingsWriter, @@ -686,6 +688,22 @@ mod tests { assert!(llm_configured_for_provider("atlascloud", &ready)); } + #[test] + fn credentials_status_requires_api_key_for_orcarouter() { + let keyless = CredentialsSnapshot { + ark_endpoint: Some("https://api.orcarouter.ai/v1".into()), + ark_model_id: Some("orcarouter/fusion-flash".into()), + ..snapshot() + }; + assert!(!llm_configured_for_provider("orcarouter", &keyless)); + + let ready = CredentialsSnapshot { + ark_api_key: Some("key".into()), + ..keyless + }; + assert!(llm_configured_for_provider("orcarouter", &ready)); + } + impl SettingsWriter for FakeSettingsWriter { fn read_settings(&self) -> UserPreferences { self.saved.lock().unwrap().clone().unwrap_or_default() @@ -809,6 +827,51 @@ mod tests { assert_eq!(models, vec!["a".to_string(), "b".to_string()]); } + #[test] + fn orcarouter_catalog_filters_non_chat_models() { + let body = r#"{"data":[ + {"id":"orcarouter/fusion-flash","supported_endpoint_types":["openai","anthropic"]}, + {"id":"openai/gpt-5-mini","supported_endpoint_types":["openai"]}, + {"id":"openai/sora-2","supported_endpoint_types":["openai-video"]}, + {"id":"openai/text-embedding-3-small","supported_endpoint_types":["embeddings"]}, + {"id":"legacy/chat-model"} + ]}"#; + assert_eq!( + parse_orcarouter_model_ids(body).unwrap(), + vec![ + "legacy/chat-model".to_string(), + "openai/gpt-5-mini".to_string(), + "orcarouter/fusion-flash".to_string(), + ] + ); + assert!(is_orcarouter_base_url("https://api.orcarouter.ai/v1")); + assert!(!is_orcarouter_base_url("https://orcarouter.ai/v1")); + assert!(!is_orcarouter_base_url( + "https://api.orcarouter.ai.example/v1" + )); + } + + #[test] + fn orcarouter_asr_catalog_keeps_audio_input_gemini_models_only() { + let body = r#"{"data":[ + {"id":"google/gemini-2.5-flash","supported_endpoint_types":["openai"]}, + {"id":"google/gemini-3.5-flash","supported_endpoint_types":["openai"]}, + {"id":"google/gemini-2.5-flash-image-preview","supported_endpoint_types":["openai"]}, + {"id":"google/gemini-2.5-flash-tts","supported_endpoint_types":["openai"]}, + {"id":"openai/whisper-large-v3","supported_endpoint_types":["openai"]}, + {"id":"google/gemini-legacy"}, + {"id":"google/gemini-native","supported_endpoint_types":["google"]} + ]}"#; + assert_eq!( + parse_orcarouter_asr_model_ids(body).unwrap(), + vec![ + "google/gemini-2.5-flash".to_string(), + "google/gemini-3.5-flash".to_string(), + "google/gemini-legacy".to_string(), + ] + ); + } + #[test] fn parse_gemini_model_ids_strips_models_prefix_and_dedups() { // Google v1beta/models 真实响应的子集——name 字段带 `models/` 前缀, diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 762771c23..3570fd845 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -188,6 +188,14 @@ pub async fn list_provider_models( models: vec![crate::asr::mimo::DEFAULT_MODEL.to_string()], }); } + if scope.kind == ProviderKind::Asr + && scope.provider_type() == crate::asr::mimo::ORCAROUTER_PROVIDER_ID + { + let config = read_openai_provider_config(scope)?; + return fetch_orcarouter_asr_models(&config) + .await + .map(|models| ProviderModelsResult { models }); + } if scope.kind == ProviderKind::Asr && scope.provider_type() == crate::asr::dashscope_multimodal::PROVIDER_ID { @@ -242,7 +250,11 @@ fn read_openai_provider_config(scope: &ProviderScope) -> Result ( CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint, - scope.provider_type() != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, + !matches!( + scope.provider_type().as_str(), + crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID + | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ), ), // 多模态(Omni)模型:独立命名空间,OpenAI 兼容通道要求 API Key + Base URL。 ProviderKind::Omni => ( @@ -440,7 +452,10 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { if active_asr == crate::asr::qwen_realtime::PROVIDER_ID { return validate_qwen3_realtime_asr_provider(scope).await; } - if active_asr == crate::asr::mimo::PROVIDER_ID { + if matches!( + active_asr.as_str(), + crate::asr::mimo::PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ) { return validate_mimo_asr_provider(scope).await; } if active_asr == crate::asr::dashscope_multimodal::PROVIDER_ID { @@ -645,8 +660,18 @@ async fn validate_mimo_asr_provider(scope: &ProviderScope) -> Result<(), String> .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); - let asr = crate::asr::MimoBatchASR::new(config.api_key, config.base_url, model); + .unwrap_or_else(|| { + if scope.provider_type() == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL.to_string() + } else { + crate::asr::mimo::DEFAULT_MODEL.to_string() + } + }); + let asr = if scope.provider_type() == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + crate::asr::MimoBatchASR::new_orcarouter(config.api_key, config.base_url, model) + } else { + crate::asr::MimoBatchASR::new(config.api_key, config.base_url, model) + }; crate::recorder::AudioConsumer::consume_pcm_chunk( &asr, &encode_wav_16k_mono_silence(250)[44..], @@ -1150,8 +1175,20 @@ fn provider_request_error_message(error: &reqwest::Error) -> &'static str { } pub(crate) async fn fetch_provider_models(config: &ProviderConfig) -> Result, String> { + fetch_provider_models_for_catalog(config, false).await +} + +async fn fetch_orcarouter_asr_models(config: &ProviderConfig) -> Result, String> { + fetch_provider_models_for_catalog(config, true).await +} + +async fn fetch_provider_models_for_catalog( + config: &ProviderConfig, + orcarouter_asr: bool, +) -> Result, String> { let url = models_url(&config.base_url); let is_gemini = is_gemini_base_url(&config.base_url); + let is_orcarouter = is_orcarouter_base_url(&config.base_url); let log_context = provider_log_context(&url, is_gemini); log::info!("[provider-check] {log_context}"); let client = http_client_builder(&config.base_url, 15) @@ -1194,6 +1231,10 @@ pub(crate) async fn fetch_provider_models(config: &ProviderConfig) -> Result bool { base_url.contains("generativelanguage.googleapis.com") } +pub(crate) fn is_orcarouter_base_url(base_url: &str) -> bool { + reqwest::Url::parse(base_url.trim()) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|host| host == "api.orcarouter.ai") +} + pub(crate) fn models_url(base_url: &str) -> String { let trimmed = base_url.trim(); let Ok(mut url) = reqwest::Url::parse(trimmed) else { @@ -1240,6 +1288,77 @@ pub(crate) fn parse_model_ids(body: &str) -> Result, String> { Ok(models) } +/// OrcaRouter's catalog covers chat, image, video, and embedding endpoints. +/// OpenLess polish uses OpenAI Chat Completions, so only expose entries that +/// advertise the `openai` endpoint type. Missing capability metadata is kept +/// for forward compatibility with older/self-hosted catalog responses. +pub(crate) fn parse_orcarouter_model_ids(body: &str) -> Result, String> { + let json: Value = + serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?; + let data = json + .get("data") + .and_then(|value| value.as_array()) + .ok_or_else(|| "模型列表缺少 data 数组".to_string())?; + let mut models = data + .iter() + .filter(|item| { + item.get("supported_endpoint_types") + .and_then(|value| value.as_array()) + .is_none_or(|endpoint_types| { + endpoint_types + .iter() + .any(|endpoint| endpoint.as_str() == Some("openai")) + }) + }) + .filter_map(|item| item.get("id").and_then(|id| id.as_str())) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + models.sort(); + models.dedup(); + Ok(models) +} + +/// OrcaRouter exposes audio input through OpenAI-shaped Chat Completions. +/// Its public catalog currently does not publish an ASR capability flag, so +/// restrict ASR choices to the documented multimodal Gemini family and remove +/// image/TTS/embedding variants. The endpoint metadata still has to advertise +/// the OpenAI protocol when present. +pub(crate) fn parse_orcarouter_asr_model_ids(body: &str) -> Result, String> { + let json: Value = + serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?; + let data = json + .get("data") + .and_then(|value| value.as_array()) + .ok_or_else(|| "模型列表缺少 data 数组".to_string())?; + let mut models = data + .iter() + .filter(|item| { + item.get("supported_endpoint_types") + .and_then(|value| value.as_array()) + .is_none_or(|endpoint_types| { + endpoint_types + .iter() + .any(|endpoint| endpoint.as_str() == Some("openai")) + }) + }) + .filter_map(|item| item.get("id").and_then(|id| id.as_str())) + .map(str::trim) + .filter(|id| { + let normalized = id.to_ascii_lowercase(); + normalized.starts_with("google/gemini") + && !["image", "tts", "embedding", "robotics"] + .iter() + .any(|marker| normalized.contains(marker)) + }) + .map(ToOwned::to_owned) + .collect::>(); + models.sort(); + models.dedup(); + Ok(models) +} + /// 谷歌 v1beta/models 响应形状:`{models: [{name: "models/gemini-2.5-flash", /// supportedGenerationMethods: ["generateContent", ...], ...}, ...]}`。 /// 与 OpenAI `{data: [{id: "..."}]}` 不兼容,所以单独解析;name 字段去掉 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index dcb38db6f..da3ea16bd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3640,6 +3640,8 @@ fn whisper_credential_defaults(provider_id: &str) -> (String, String) { } fn read_mimo_credentials() -> (String, String, String) { + let active_asr = CredentialsVault::get_active_asr(); + let orcarouter = active_asr == crate::asr::mimo::ORCAROUTER_PROVIDER_ID; let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() .flatten() @@ -3648,12 +3650,24 @@ fn read_mimo_credentials() -> (String, String, String) { .ok() .flatten() .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string()); + .unwrap_or_else(|| { + if orcarouter { + crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT.to_string() + } else { + crate::asr::mimo::DEFAULT_ENDPOINT.to_string() + } + }); let model = CredentialsVault::get(CredentialAccount::AsrModel) .ok() .flatten() .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); + .unwrap_or_else(|| { + if orcarouter { + crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL.to_string() + } else { + crate::asr::mimo::DEFAULT_MODEL.to_string() + } + }); (api_key, base_url, model) } @@ -4576,6 +4590,9 @@ mod tests { assert!(!is_whisper_compatible_provider( crate::asr::mimo::PROVIDER_ID )); + assert!(!is_whisper_compatible_provider( + crate::asr::mimo::ORCAROUTER_PROVIDER_ID + )); } #[test] @@ -4815,6 +4832,10 @@ mod tests { active_asr_provider_kind(crate::asr::mimo::PROVIDER_ID), ActiveAsrProviderKind::Mimo ); + assert_eq!( + active_asr_provider_kind(crate::asr::mimo::ORCAROUTER_PROVIDER_ID), + ActiveAsrProviderKind::Mimo + ); assert_eq!( active_asr_provider_kind(crate::asr::dashscope_multimodal::PROVIDER_ID), ActiveAsrProviderKind::DashScopeMultimodal diff --git a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs index a590e7863..cd14f2e9d 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -848,7 +848,10 @@ pub(super) fn is_stepfun_realtime_provider(id: &str) -> bool { } pub(super) fn is_mimo_provider(id: &str) -> bool { - id == crate::asr::mimo::PROVIDER_ID + matches!( + id, + crate::asr::mimo::PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ) } pub(super) fn is_dashscope_multimodal_provider(id: &str) -> bool { @@ -1128,7 +1131,13 @@ pub(super) async fn build_qa_asr_start( ActiveAsrProviderKind::Mimo => { let (api_key, base_url, model) = read_mimo_credentials(); let label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); - let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); + let mimo = Arc::new( + if effective_asr == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + MimoBatchASR::new_orcarouter(api_key, base_url, model) + } else { + MimoBatchASR::new(api_key, base_url, model) + }, + ); let active = ActiveAsr::Mimo(Arc::clone(&mimo)); let consumer: Arc = mimo; Ok((QaAsrStart::Ready { active, consumer }, label)) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index eda814b39..a389cd20d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2758,7 +2758,13 @@ pub(super) async fn begin_session_as( } else if is_mimo_provider(&effective_asr) { let (api_key, base_url, model) = read_mimo_credentials(); let asr_call_label = AsrCallLabel::new(effective_asr.clone(), Some(model.clone())); - let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); + let mimo = Arc::new( + if effective_asr == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + MimoBatchASR::new_orcarouter(api_key, base_url, model) + } else { + MimoBatchASR::new(api_key, base_url, model) + }, + ); store_asr_for_session( inner, current_session_id, diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d868641fc..55157e0ed 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -172,6 +172,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "mimo" | "cometapi" | "openrouterFree" + | "orcarouter" | "alibabaCoding" | "codingPlanX" | "minimax" @@ -3494,6 +3495,10 @@ mod tests { openai_compatible_temperature_for_provider("atlascloud", None), Some(DEFAULT_TEMPERATURE) ); + assert_eq!( + openai_compatible_temperature_for_provider("orcarouter", None), + Some(DEFAULT_TEMPERATURE) + ); } #[test] diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 84f73c65f..892e98d8e 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -48,6 +48,10 @@ interface SelectLiteProps { ariaLabel?: string; /** 下拉打开 / 关闭时回调 —— 让调用方按开合状态启停副作用(如电平监听)。 */ onOpenChange?: (open: boolean) => void; + /** 长列表可在 popover 顶部显示搜索框;筛选只匹配 label/value,不改原 options。 */ + searchable?: boolean; + searchPlaceholder?: string; + emptyMessage?: string; } const DEFAULT_TRIGGER_STYLE: CSSProperties = { @@ -80,12 +84,17 @@ export function SelectLite({ style, ariaLabel, onOpenChange, + searchable = false, + searchPlaceholder = 'Search…', + emptyMessage = 'No matching options', }: SelectLiteProps) { const [open, setOpen] = useState(false); // leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount) const [leaving, setLeaving] = useState(false); const [highlight, setHighlight] = useState(-1); + const [query, setQuery] = useState(''); const triggerRef = useRef(null); + const searchRef = useRef(null); const popoverRef = useRef(null); const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null); // popoverMounted 让 useLayoutEffect 在 popover 实际进入 DOM 后再触发一次 positionPopover, @@ -96,6 +105,14 @@ export function SelectLite({ () => options.find(opt => opt.value === value), [options, value], ); + const filteredOptions = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return options; + return options.filter(option => ( + option.label.toLocaleLowerCase().includes(needle) + || option.value.toLocaleLowerCase().includes(needle) + )); + }, [options, query]); const displayLabel = selected?.label ?? placeholder ?? ''; const positionPopover = useCallback(() => { @@ -157,9 +174,32 @@ export function SelectLite({ const target = popoverRef.current?.querySelector( `[data-option-index="${highlight}"]`, ) as HTMLElement | null; - target?.scrollIntoView({ block: 'nearest' }); + const scrollContainer = target?.parentElement; + if (!target || !scrollContainer) return; + const optionTop = target.offsetTop; + const optionBottom = optionTop + target.offsetHeight; + if (optionTop < scrollContainer.scrollTop) { + scrollContainer.scrollTop = optionTop; + } else if (optionBottom > scrollContainer.scrollTop + scrollContainer.clientHeight) { + scrollContainer.scrollTop = optionBottom - scrollContainer.clientHeight; + } }, [highlight, open]); + useEffect(() => { + if (!open || !searchable || !popoverMounted) return; + searchRef.current?.focus(); + }, [open, popoverMounted, searchable]); + + useEffect(() => { + if (!open) return; + const selectedIndex = filteredOptions.findIndex( + option => option.value === value && !option.disabled, + ); + setHighlight(selectedIndex >= 0 + ? selectedIndex + : filteredOptions.findIndex(option => !option.disabled)); + }, [filteredOptions, open, value]); + // 点击外部 / 滚动外部 → 关闭。popover 内部 scroll 保持打开。 useEffect(() => { if (!open) return; @@ -177,8 +217,8 @@ export function SelectLite({ if (target && popoverRef.current?.contains(target)) return; closeMenu(); }; - // window resize 强制关闭:重算位置成本高且大多数 resize 表明 user 不再想看 popover。 - const handleResize = () => closeMenu(); + // 窗口尺寸变化时保持展开并重新锚定,避免桌面窗口轻微调整就丢失当前筛选。 + const handleResize = () => positionPopover(); document.addEventListener('mousedown', handlePointerDown); window.addEventListener('scroll', handleScrollOutside, { capture: true, passive: true }); @@ -192,10 +232,11 @@ export function SelectLite({ }; // closeMenu 是稳定引用(无 React state 依赖),不放 deps。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, positionPopover]); const openMenu = () => { if (disabled) return; + setQuery(''); const initial = options.findIndex(opt => opt.value === value && !opt.disabled); setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled)); setLeaving(false); @@ -211,12 +252,13 @@ export function SelectLite({ setOpen(false); setLeaving(false); setHighlight(-1); + setQuery(''); setAnchor(null); }, EXIT_ANIM_MS); }; const selectIndex = (index: number) => { - const option = options[index]; + const option = filteredOptions[index]; if (!option || option.disabled) return; onChange(option.value); closeMenu(); @@ -224,11 +266,11 @@ export function SelectLite({ }; const moveHighlight = (direction: 1 | -1) => { - if (options.length === 0) return; + if (filteredOptions.length === 0) return; let next = highlight; - for (let i = 0; i < options.length; i += 1) { - next = (next + direction + options.length) % options.length; - if (!options[next]?.disabled) { + for (let i = 0; i < filteredOptions.length; i += 1) { + next = (next + direction + filteredOptions.length) % filteredOptions.length; + if (!filteredOptions[next]?.disabled) { setHighlight(next); return; } @@ -261,6 +303,22 @@ export function SelectLite({ } }; + const handleSearchKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeMenu(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + moveHighlight(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + moveHighlight(-1); + } else if (event.key === 'Enter' && highlight >= 0) { + event.preventDefault(); + selectIndex(highlight); + } + }; + const triggerStyle: CSSProperties = { ...DEFAULT_TRIGGER_STYLE, ...style, @@ -313,8 +371,8 @@ export function SelectLite({ // 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出 // trigger 范围;长 label 走 textOverflow:ellipsis 截断。 width: anchor.width, - maxHeight: 280, - overflowY: 'auto', + maxHeight: searchable ? 320 : 280, + overflow: 'hidden', padding: 4, borderRadius: 10, border: '0.5px solid var(--ol-select-popover-border)', @@ -331,48 +389,101 @@ export function SelectLite({ transformOrigin: 'top center', }} > - {options.map((option, index) => { - const isSelected = option.value === value; - const isHighlighted = index === highlight; - return ( -
!option.disabled && setHighlight(index)} - onMouseDown={event => { - event.preventDefault(); - selectIndex(index); + {searchable && ( +
+ + setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + style={{ + flex: 1, + minWidth: 0, + border: 0, + outline: 0, + padding: 0, + background: 'transparent', + color: 'var(--ol-ink)', + font: 'inherit', }} + /> + + {filteredOptions.length}/{options.length} + +
+ )} +
+ {filteredOptions.length === 0 && ( +
- - {option.label} - - {option.trailing} - {isSelected && } + {emptyMessage}
- ); - })} + )} + {filteredOptions.map((option, index) => { + const isSelected = option.value === value; + const isHighlighted = index === highlight; + return ( +
!option.disabled && setHighlight(index)} + onMouseDown={event => { + event.preventDefault(); + selectIndex(index); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '7px 10px', + borderRadius: 6, + cursor: option.disabled ? 'not-allowed' : 'default', + opacity: option.disabled ? 0.45 : 1, + background: isHighlighted && !option.disabled + ? 'var(--ol-select-option-hover-bg)' + : 'transparent', + color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)', + fontWeight: isSelected ? 600 : 500, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + transition: 'background 0.10s var(--ol-motion-quick)', + }} + > + + {option.label} + + {option.trailing} + {isSelected && } +
+ ); + })} +
, document.body, )} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 42192c2ed..0d40e4165 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1006,6 +1006,7 @@ export const en: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', @@ -1077,6 +1078,10 @@ export const en: typeof zhCN = { modelLabel: 'Model', customModelLabel: 'Custom model\u2026', presetListLabel: 'Back to presets', + searchModels: 'Search models…', + noMatchingModels: 'No matching models', + orcarouterCatalogHint: 'Loaded from OrcaRouter /models. Select a catalog model; manual model IDs are disabled for this provider.', + orcarouterAsrCatalogHint: 'Loaded from OrcaRouter /models and limited to Gemini models compatible with audio input. Manual model IDs are disabled.', temperatureLabel: 'Temperature', temperaturePlaceholder: 'Leave empty to omit; range 0–2 inclusive, e.g. 0.3', extraHeadersLabel: 'Extra headers', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2b5463ff6..0ad6e0f43 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1008,6 +1008,7 @@ export const ja: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1079,6 +1080,10 @@ export const ja: typeof zhCN = { modelLabel: 'モデル', customModelLabel: 'カスタムモデル…', presetListLabel: 'プリセットに戻る', + searchModels: 'モデルを検索…', + noMatchingModels: '一致するモデルがありません', + orcarouterCatalogHint: 'OrcaRouter /models から取得します。このプロバイダーではカタログから選択し、モデル ID の手入力はできません。', + orcarouterAsrCatalogHint: 'OrcaRouter /models から取得し、音声入力に対応する Gemini のみ表示します。モデル ID の手入力はできません。', temperatureLabel: 'Temperature', temperaturePlaceholder: '空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3', extraHeadersLabel: '追加 Headers', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3ad19f3b..ccb8e325c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1008,6 +1008,7 @@ export const ko: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', + orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', @@ -1079,6 +1080,10 @@ export const ko: typeof zhCN = { modelLabel: '모델', customModelLabel: '사용자 정의 모델…', presetListLabel: '프리셋으로 돌아가기', + searchModels: '모델 검색…', + noMatchingModels: '일치하는 모델이 없습니다', + orcarouterCatalogHint: 'OrcaRouter /models에서 불러옵니다. 이 공급자는 카탈로그 모델만 선택할 수 있으며 모델 ID 직접 입력은 지원하지 않습니다.', + orcarouterAsrCatalogHint: 'OrcaRouter /models에서 불러오며 오디오 입력을 지원하는 Gemini 모델만 표시합니다. 모델 ID 직접 입력은 지원하지 않습니다.', temperatureLabel: 'Temperature', temperaturePlaceholder: '비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3', extraHeadersLabel: '추가 Headers', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 816224d3e..81b82564a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1004,6 +1004,7 @@ export const zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', + orcarouter: 'OrcaRouter', alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1075,6 +1076,10 @@ export const zhCN = { modelLabel: '模型', customModelLabel: '自定义模型…', presetListLabel: '返回预设列表', + searchModels: '搜索模型…', + noMatchingModels: '没有匹配的模型', + orcarouterCatalogHint: '模型来自 OrcaRouter /models;此供应商只允许从目录中选择,不支持手动填写模型 ID。', + orcarouterAsrCatalogHint: '模型来自 OrcaRouter /models,并仅显示兼容音频输入的 Gemini;不支持手动填写模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空则不发送;范围 0~2(含边界),例如 0.3', extraHeadersLabel: '额外 Headers', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 534421198..9d63351fa 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1006,6 +1006,7 @@ export const zhTW: typeof zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', + orcarouter: 'OrcaRouter', alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', @@ -1077,6 +1078,10 @@ export const zhTW: typeof zhCN = { modelLabel: '模型', customModelLabel: '自訂模型…', presetListLabel: '返回預設清單', + searchModels: '搜尋模型…', + noMatchingModels: '沒有符合的模型', + orcarouterCatalogHint: '模型來自 OrcaRouter /models;此供應商只允許從目錄中選擇,不支援手動填寫模型 ID。', + orcarouterAsrCatalogHint: '模型來自 OrcaRouter /models,並僅顯示相容音訊輸入的 Gemini;不支援手動填寫模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空則不發送;範圍 0~2(含邊界),例如 0.3', extraHeadersLabel: '額外 Headers', diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index d5a6e9695..3620f89ad 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,7 +1,16 @@ import type { CredentialsStatus } from "../types" -import { invokeOrMock } from "./shared" +import { invokeOrMock, isTauri } from "./shared" import { mockCredentialsStatus } from "./mock-data" +const browserCredentialMock = new Map([ + ["orcarouter-asr:asr.endpoint", "https://api.orcarouter.ai/v1"], + ["orcarouter-asr:asr.model", "google/gemini-2.5-flash"], +]) + +function browserCredentialKey(account: string, provider?: string): string { + return `${provider ?? "active"}:${account}` +} + export interface ProviderCheckResult { ok: boolean } @@ -19,7 +28,9 @@ export function getCredentials(): Promise { } export function setCredential(account: string, value: string, provider?: string): Promise { - return invokeOrMock("set_credential", { account, value, provider }, () => undefined) + return invokeOrMock("set_credential", { account, value, provider }, () => { + browserCredentialMock.set(browserCredentialKey(account, provider), value) + }) } export function setActiveAsrProvider(provider: string): Promise { @@ -50,7 +61,7 @@ export function readCredential(account: string, provider?: string): Promise( "read_credential", { account, provider }, - () => null, + () => browserCredentialMock.get(browserCredentialKey(account, provider)) ?? null, ) } @@ -64,10 +75,46 @@ export function validateProviderCredentials( })) } -export function listProviderModels( +export async function listProviderModels( kind: "llm" | "asr" | "omni", channelId?: string, ): Promise { + if (!isTauri && (kind === "llm" || kind === "asr")) { + const endpointAccount = kind === "llm" ? "ark.endpoint" : "asr.endpoint" + const endpoint = browserCredentialMock.get( + browserCredentialKey(endpointAccount, channelId), + ) + if (endpoint) { + const host = new URL(endpoint).hostname.toLowerCase() + if (host === "api.orcarouter.ai") { + const response = await fetch("/__openless_dev/orcarouter/models") + if (!response.ok) { + throw new Error(`OrcaRouter /models returned ${response.status}`) + } + const payload = await response.json() as { + data?: Array<{ + id?: string + supported_endpoint_types?: string[] + }> + } + return { + models: (payload.data ?? []) + .filter(model => ( + !model.supported_endpoint_types + || model.supported_endpoint_types.includes("openai") + )) + .map(model => model.id?.trim() ?? "") + .filter(model => { + if (!model) return false + if (kind === "llm") return true + const id = model.toLowerCase() + return id.startsWith("google/gemini") + && !/(image|tts|embedding|robotics)/.test(id) + }), + } + } + } + } return invokeOrMock("list_provider_models", { kind, channelId }, () => ({ models: kind === "llm" diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 14ca64841..2f1fc9997 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -75,9 +75,19 @@ const mockChannels: Record = { order: 1, lastTest: null, }, + { + id: "orcarouter-asr", + name: "OrcaRouter-ASR", + providerType: "orcarouter", + enabled: false, + order: 2, + lastTest: null, + }, ], } +let mockChannelSequence = 0 + export function listChannels(kind: ChannelKind): Promise { return invokeOrMock("list_channels", { kind }, () => mockChannels[kind]) } @@ -91,7 +101,19 @@ export function createChannel( return invokeOrMock( "create_channel", { kind, providerType, name }, - () => providerType, + () => { + mockChannelSequence += 1 + const id = `preview-${kind}-${mockChannelSequence}` + mockChannels[kind].push({ + id, + name, + providerType, + enabled: true, + order: mockChannels[kind].length, + lastTest: null, + }) + return id + }, ) } @@ -104,7 +126,11 @@ export function setChannelProviderType( return invokeOrMock( "set_channel_provider_type", { kind, id, providerType }, - () => undefined, + () => { + const channel = mockChannels[kind].find(item => item.id === id) + if (channel) channel.providerType = providerType + return undefined + }, ) } @@ -113,7 +139,11 @@ export function deleteChannelIfBlank( kind: ChannelKind, id: string, ): Promise { - return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true) + return invokeOrMock("delete_channel_if_blank", { kind, id }, () => { + const before = mockChannels[kind].length + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + return mockChannels[kind].length !== before + }) } export function renameChannel( @@ -121,11 +151,18 @@ export function renameChannel( id: string, name: string, ): Promise { - return invokeOrMock("rename_channel", { kind, id, name }, () => undefined) + return invokeOrMock("rename_channel", { kind, id, name }, () => { + const channel = mockChannels[kind].find(item => item.id === id) + if (channel) channel.name = name + return undefined + }) } export function deleteChannel(kind: ChannelKind, id: string): Promise { - return invokeOrMock("delete_channel", { kind, id }, () => undefined) + return invokeOrMock("delete_channel", { kind, id }, () => { + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + return undefined + }) } export function setChannelEnabled( @@ -136,7 +173,11 @@ export function setChannelEnabled( return invokeOrMock( "set_channel_enabled", { kind, id, enabled }, - () => undefined, + () => { + const channel = mockChannels[kind].find(item => item.id === id) + if (channel) channel.enabled = enabled + return undefined + }, ) } @@ -171,6 +212,17 @@ export function recordChannelTest( return invokeOrMock( "record_channel_test", { kind, id, ok, latencyMs, error }, - () => undefined, + () => { + const channel = mockChannels[kind].find(item => item.id === id) + if (channel) { + channel.lastTest = { + ok, + latencyMs, + error, + at: Math.floor(Date.now() / 1000), + } + } + return undefined + }, ) } diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 577d39d85..e46aeea21 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -1994,7 +1994,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { sidebarEntries.some((e) => e.id === selectedModelId) if (stillExists) return const firstDownloaded = sidebarEntries.find((e) => e.isDownloaded) - setSelectedModelId(firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null) + const fallbackId = firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null + if (fallbackId !== selectedModelId) setSelectedModelId(fallbackId) }, [sidebarEntries, selectedModelId, downloadDialogOpen]) // 从侧栏/看板分派引擎动作。不再有 setActive——激活 = 在 ASR 语音转写里 diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6eeddd610..ea918cbdc 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -50,6 +50,7 @@ const LLM_NAME_KEY_BY_ID: Record = { mimo: 'mimo', cometapi: 'cometapi', openrouterFree: 'openrouterFree', + orcarouter: 'orcarouter', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', custom: 'custom', diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index 540106832..3dcd9fbb8 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -1,5 +1,9 @@ import type { OS } from '../../components/WindowChrome'; -import { presetsFor, shouldRecycleDraft } from './ChannelList'; +import { + defaultChannelNameForProvider, + presetsFor, + shouldRecycleDraft, +} from './ChannelList'; const localProviders = [ 'local-qwen3', @@ -75,4 +79,14 @@ if (shouldRecycleDraft(null, false)) { throw new Error('an existing channel must never enter draft cleanup'); } +if (defaultChannelNameForProvider('orcarouter', '') !== 'OrcaRouter') { + throw new Error('a blank OrcaRouter channel must receive the OrcaRouter default name'); +} +if (defaultChannelNameForProvider('orcarouter', 'My primary key') !== 'My primary key') { + throw new Error('the OrcaRouter default name must not overwrite a custom channel name'); +} +if (defaultChannelNameForProvider('openai', '') !== '') { + throw new Error('non-OrcaRouter channel naming must remain unchanged'); +} + console.log('ChannelList platform filtering and draft lifecycle tests passed'); diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index a30cc1c5c..8bbe10adb 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -86,6 +86,12 @@ export function shouldRecycleDraft(draftId: string | null, touched: boolean): bo return draftId != null && !touched; } +/** OrcaRouter 渠道使用统一的无空格品牌名;只填空名称,不覆盖用户自定义命名。 */ +export function defaultChannelNameForProvider(providerType: string, currentName: string): string { + if (currentName.trim() || providerType !== 'orcarouter') return currentName; + return 'OrcaRouter'; +} + function presetLabel( kind: ChannelKind, providerType: string, @@ -723,6 +729,15 @@ function ChannelModal({ if (kind === 'llm') { const preset = LLM_PRESETS.find(p => p.id === next); if (!preset || preset.id === 'custom' || preset.id === 'codex_oauth') return; + // OrcaRouter's model field immediately calls its /models catalog. A + // provider switch must therefore replace the previous vendor endpoint + // before that field mounts, otherwise the first catalog request could + // be sent to the old provider. + if (preset.id === 'orcarouter') { + await setCredential('ark.endpoint', preset.baseUrl, channel.id); + await setCredential('ark.model_id', preset.modelPlaceholder, channel.id); + return; + } if (preset.baseUrl && !(await readCredential('ark.endpoint', channel.id))?.trim()) { await setCredential('ark.endpoint', preset.baseUrl, channel.id); } @@ -736,6 +751,13 @@ function ChannelModal({ } const preset = ASR_PRESETS.find(p => p.id === next); if (!preset) return; + // 与 LLM 渠道一致,切换到 OrcaRouter 时先替换旧供应商的地址和模型, + // 让随后挂载的目录字段从正确的 /models 加载。 + if (preset.id === 'orcarouter') { + await setCredential('asr.endpoint', preset.baseUrl, channel.id); + await setCredential('asr.model', preset.model, channel.id); + return; + } if (preset.baseUrl && !(await readCredential('asr.endpoint', channel.id))?.trim()) { await setCredential('asr.endpoint', preset.baseUrl, channel.id); } @@ -750,10 +772,23 @@ function ChannelModal({ const changeProvider = async (next: string) => { const previous = providerType; onUserMutation(); - setProviderType(next); try { await setChannelProviderType(kind, channel.id, next); await fillProviderDefaults(next); + // Mount the new provider fields only after their defaults are persisted; + // catalog-backed fields can then load without racing stale credentials. + setProviderType(next); + + const defaultName = defaultChannelNameForProvider(next, name); + if (defaultName !== name) { + try { + await renameChannel(kind, channel.id, defaultName); + setName(defaultName); + } catch (error) { + console.error('[channels] failed to apply provider default name', error); + emitSaved('failed', t('common.operationFailed')); + } + } await onChanged(); } catch (error) { console.error('[channels] change provider failed', error); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 371d5e4f2..43d23f2a5 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,4 +1,4 @@ -import { LLM_PRESETS } from './ProvidersSection'; +import { LLM_PRESETS, prioritizeOrcaRouterModels } from './ProvidersSection'; import { ASR_PRESETS } from './shared'; const atlascloudPreset = LLM_PRESETS.find(p => p.id === 'atlascloud'); @@ -15,6 +15,50 @@ if (atlascloudPreset.modelPlaceholder !== 'qwen/qwen3.5-flash') { throw new Error(`unexpected Atlas Cloud default model: ${atlascloudPreset.modelPlaceholder}`); } +const orcarouterPreset = LLM_PRESETS.find(p => p.id === 'orcarouter'); + +if (!orcarouterPreset) { + throw new Error('OrcaRouter LLM preset is missing'); +} + +if (orcarouterPreset.baseUrl !== 'https://api.orcarouter.ai/v1') { + throw new Error(`unexpected OrcaRouter base URL: ${orcarouterPreset.baseUrl}`); +} + +if (orcarouterPreset.modelPlaceholder !== 'orcarouter/fusion-flash') { + throw new Error(`unexpected OrcaRouter default model: ${orcarouterPreset.modelPlaceholder}`); +} + +const orcarouterAsrPreset = ASR_PRESETS.find(p => p.id === 'orcarouter'); + +if (!orcarouterAsrPreset) { + throw new Error('OrcaRouter ASR preset is missing'); +} + +if (orcarouterAsrPreset.baseUrl !== 'https://api.orcarouter.ai/v1') { + throw new Error(`unexpected OrcaRouter ASR base URL: ${orcarouterAsrPreset.baseUrl}`); +} + +if (orcarouterAsrPreset.model !== 'google/gemini-2.5-flash') { + throw new Error(`unexpected OrcaRouter ASR default model: ${orcarouterAsrPreset.model}`); +} + +const prioritizedOrcaRouterModels = prioritizeOrcaRouterModels([ + 'openai/gpt-5-mini', + 'orcarouter/fusion-mini', + 'anthropic/claude-haiku-4.5', + 'orcarouter/fusion-flash', +]); + +if (prioritizedOrcaRouterModels.join(',') !== [ + 'orcarouter/fusion-flash', + 'orcarouter/fusion-mini', + 'anthropic/claude-haiku-4.5', + 'openai/gpt-5-mini', +].join(',')) { + throw new Error(`unexpected OrcaRouter model ordering: ${prioritizedOrcaRouterModels.join(',')}`); +} + const openAiCompatiblePreset = ASR_PRESETS.find(p => p.id === 'openai-compatible'); if (!openAiCompatiblePreset) { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a315b7662..158d8bd81 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -151,6 +151,14 @@ export const LLM_PRESETS = [ baseUrl: 'https://openrouter.ai/api/v1', modelPlaceholder: 'qwen/qwen3-coder:free', }, + { + id: 'orcarouter', + nameKey: 'orcarouter', + baseUrl: 'https://api.orcarouter.ai/v1', + // The catalog-only field below verifies this default against GET /models + // before saving it, so a stale default can never strand the user. + modelPlaceholder: 'orcarouter/fusion-flash', + }, { id: 'alibabaCoding', nameKey: 'alibabaCoding', @@ -378,21 +386,37 @@ export function ChannelCredentialFields({ )} )} - - )} - /> + {providerType === 'orcarouter' ? ( + + )} + /> + ) : ( + + )} + /> + )} setLlmModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} /> + onUserMutation={onUserMutation} showFetchModels={providerType !== 'orcarouter'} /> ); } @@ -488,6 +512,29 @@ export function ChannelCredentialFields({ ); } + if (providerType === 'orcarouter') { + return ( + <> + + + + setAsrModelRevision(v => v + 1)} onTested={onTested} + onUserMutation={onUserMutation} showFetchModels={false} /> + + ); + } + return ( <> { + const leftOwn = left.startsWith('orcarouter/'); + const rightOwn = right.startsWith('orcarouter/'); + if (leftOwn !== rightOwn) return leftOwn ? -1 : 1; + return left.localeCompare(right); + }); +} + +function CatalogModelField({ + kind, + provider, + baseUrl, + defaultModel, + trailing, + onUserMutation, +}: { + kind: 'llm' | 'asr'; + provider: string; + baseUrl: string; + defaultModel: string; + trailing?: ReactNode; + onUserMutation?: () => void; +}) { + const { t } = useTranslation(); + const baseLayoutStack = useLayoutStack(); + const conservative = useConservativeLayout(); + const layoutStack = conservative || baseLayoutStack; + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState(t('settings.providers.loadingModels')); + const requestRef = useRef(0); + const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; + const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; + + const loadModels = async (initialize: boolean) => { + const requestId = ++requestRef.current; + setStatus('loading'); + setMessage(t('settings.providers.loadingModels')); + try { + // A newly created/migrated channel may not have received its preset yet. + // Fill only an empty endpoint here; an explicit endpoint edit remains + // respected, matching the behavior of the other named providers. + if (initialize) { + const endpoint = await readCredential(endpointAccount, provider); + if (!endpoint?.trim()) { + await setCredential(endpointAccount, baseUrl, provider); + } + } + const [savedModel, result] = await Promise.all([ + readCredential(modelAccount, provider), + listProviderModels(kind, provider), + ]); + if (requestId !== requestRef.current) return; + const nextModels = prioritizeOrcaRouterModels(result.models); + setModels(nextModels); + if (nextModels.length === 0) { + setSelectedModel(''); + setStatus('empty'); + setMessage(t('settings.providers.modelsEmpty')); + return; + } + + const current = savedModel?.trim() ?? ''; + const nextModel = nextModels.includes(current) + ? current + : nextModels.includes(defaultModel) + ? defaultModel + : nextModels[0]; + if (nextModel !== current) { + await setCredential(modelAccount, nextModel, provider); + } + if (requestId !== requestRef.current) return; + setSelectedModel(nextModel); + setStatus('success'); + setMessage(t('settings.providers.modelsLoaded', { count: nextModels.length })); + } catch (error) { + if (requestId !== requestRef.current) return; + setModels([]); + setStatus('error'); + setMessage(providerErrorMessage(error, t)); + } + }; + + useEffect(() => { + void loadModels(true); + return () => { + requestRef.current += 1; + }; + // The channel id defines the credential scope; changing it must reload the catalog. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]); + + const applyModel = async (model: string) => { + onUserMutation?.(); + setStatus('loading'); + setMessage(t('common.saving')); + try { + await setCredential(modelAccount, model, provider); + setSelectedModel(model); + setStatus('success'); + setMessage(t('settings.providers.modelSaved', { model })); + emitSaved('saved', t('common.saved')); + } catch (error) { + setStatus('error'); + setMessage(providerErrorMessage(error, t)); + emitSaved('failed', t('common.operationFailed')); + } + }; + + return ( + +
+
+ void applyModel(model)} + options={models.map(model => ({ value: model, label: model }))} + placeholder={status === 'loading' + ? t('settings.providers.loadingModels') + : t('settings.providers.selectModel')} + disabled={status === 'loading' || models.length === 0} + searchable + searchPlaceholder={t('settings.providers.searchModels')} + emptyMessage={t('settings.providers.noMatchingModels')} + ariaLabel={t('settings.providers.selectModel')} + style={{ + flex: layoutStack ? '1 1 100%' : 1, + width: '100%', + minWidth: 0, + maxWidth: '100%', + fontFamily: 'var(--ol-font-mono)', + }} + /> + + {trailing} +
+ + {message} + + + {t(kind === 'asr' + ? 'settings.providers.orcarouterAsrCatalogHint' + : 'settings.providers.orcarouterCatalogHint')} + +
+
+ ); +} + function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index b08bd10e4..d5bcef84c 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -245,6 +245,9 @@ export const ASR_PRESETS = [ // OpenRouter 的 /audio/transcriptions 走 application/json + base64(issue #582), // 后端 coordinator.rs::whisper_request_format 对该 id 切换到 OpenRouterJson 编码。 { id: 'openrouter', nameKey: 'asrOpenrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/whisper-large-v3-turbo' }, + // OrcaRouter 的 ASR 走 /chat/completions + input_audio,由 /models 动态筛选 + // 支持音频输入的 Gemini;不是 Whisper /audio/transcriptions 协议。 + { id: 'orcarouter', nameKey: 'orcarouter', baseUrl: 'https://api.orcarouter.ai/v1', model: 'google/gemini-2.5-flash' }, // ZenMux 聚合平台的 /audio/transcriptions 同为 application/json + base64 // (issue #837),后端 whisper_request_format 对该 id 切 ZenMuxJson; // 语言跟随工作语言,enable_itn 开关见 AsrAdvancedOptions(仅该预设显示)。 diff --git a/openless-all/app/vite.config.ts b/openless-all/app/vite.config.ts index b6b1637b4..613142138 100644 --- a/openless-all/app/vite.config.ts +++ b/openless-all/app/vite.config.ts @@ -31,6 +31,15 @@ export default defineConfig(async () => ({ ? { protocol: "ws", host, port: 1421 } : undefined, watch: { ignored: ["**/src-tauri/**"] }, + proxy: { + // Browser-only preview parity: the native app calls OrcaRouter directly, + // while Vite needs a same-origin bridge because /models does not advertise CORS. + "/__openless_dev/orcarouter/models": { + target: "https://api.orcarouter.ai", + changeOrigin: true, + rewrite: () => "/v1/models", + }, + }, }, envPrefix: ["VITE_", "TAURI_"], build: {