diff --git a/README.md b/README.md index be2cc9358..e9e0bb4ce 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,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, StepFun, and OpenCode Zen — 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, StepFun, and OpenCode Zen — 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 052e317a1..4dde3063d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,9 +203,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、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/crates/openless-core/src/asr/mimo.rs b/openless-all/app/crates/openless-core/src/asr/mimo.rs index c92b0ff51..1eed47622 100644 --- a/openless-all/app/crates/openless-core/src/asr/mimo.rs +++ b/openless-all/app/crates/openless-core/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); @@ -80,7 +103,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 @@ -89,15 +112,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()) } @@ -128,22 +154,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, }], }) } @@ -298,6 +344,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/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 54113e645..0793bbe46 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -55,6 +55,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[ "groq", "whisper", "openrouter", + "orcarouter", "zenmux", "openai-compatible", "xiaomi-mimo-asr", @@ -72,6 +73,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "mimo", "cometapi", "openrouterFree", + "orcarouter", "alibabaCoding", "codingPlanX", "minimax", @@ -437,14 +439,16 @@ async fn build_cloud_transcription_session( ActiveAsrProviderKind::Mimo => { require_configured(&api_key, "ASR API key")?; let effective_model = non_blank_owned(model) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); + .unwrap_or_else(|| default_asr_model(provider_type).unwrap().to_string()); + let endpoint = non_blank_owned(endpoint) + .unwrap_or_else(|| default_asr_endpoint(provider_type).unwrap().to_string()); + let provider = if provider_type == crate::asr::mimo::ORCAROUTER_PROVIDER_ID { + MimoBatchASR::new_orcarouter(api_key, endpoint, effective_model.clone()) + } else { + MimoBatchASR::new(api_key, endpoint, effective_model.clone()) + }; ( - CloudTranscriptionSessionKind::Mimo(Arc::new(MimoBatchASR::new( - api_key, - non_blank_owned(endpoint) - .unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string()), - effective_model.clone(), - ))), + CloudTranscriptionSessionKind::Mimo(Arc::new(provider)), Some(effective_model), ) } diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 23857658e..9bd15dc11 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -183,6 +183,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "mimo" | "cometapi" | "openrouterFree" + | "orcarouter" | "alibabaCoding" | "codingPlanX" | "minimax" @@ -1726,7 +1727,9 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Some(ThinkingControl::EnableThinking), // StepFun step-3.x-flash 系列按官方文档接受 reasoning_effort(low/medium/high, // 无法完全关闭思考);非推理模型(如 step-1o-turbo-vision)会忽略该字段。 - "openai" | "codingPlanX" | "stepfun" => Some(ThinkingControl::ReasoningEffort), + "openai" | "orcarouter" | "codingPlanX" | "stepfun" => { + Some(ThinkingControl::ReasoningEffort) + } // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 _ => None, @@ -2895,6 +2898,26 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + fn orcarouter_chat_body_maps_thinking_toggle_to_reasoning_effort() { + for (enabled, expected) in [(false, "low"), (true, "medium")] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "orcarouter", + "OrcaRouter", + "https://api.orcarouter.ai/v1", + "k", + "google/gemini-2.5-flash", + ) + .with_thinking_enabled(enabled), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["reasoning_effort"], expected); + } + } + #[test] fn chat_body_omits_temperature_for_unconfigured_custom_provider() { let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index e79f9e20b..50aad94ac 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -34,6 +34,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ ("groq", "asrGroq"), ("whisper", "asrWhisper"), ("openrouter", "asrOpenrouter"), + ("orcarouter", "orcarouter"), ("zenmux", "asrZenmux"), (OPENAI_COMPATIBLE_ASR_PROVIDER_ID, "asrOpenAiCompatible"), ("xiaomi-mimo-asr", "asrXiaomiMimo"), @@ -58,6 +59,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("mimo", "mimo"), ("cometapi", "cometapi"), ("openrouterFree", "openrouterFree"), + ("orcarouter", "orcarouter"), ("alibabaCoding", "alibabaCoding"), ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), @@ -456,6 +458,7 @@ pub fn default_asr_endpoint(provider_type: &str) -> Option<&'static str> { "groq" => Some("https://api.groq.com/openai/v1"), "whisper" => Some("https://api.openai.com/v1"), "openrouter" => Some("https://openrouter.ai/api/v1"), + "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT), "zenmux" => Some("https://zenmux.ai/api/v1"), "xiaomi-mimo-asr" => Some("https://api.xiaomimimo.com/v1"), _ => None, @@ -474,6 +477,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { "groq" => Some("whisper-large-v3-turbo"), "whisper" => Some("whisper-1"), "openrouter" => Some("openai/whisper-large-v3-turbo"), + "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL), "zenmux" => Some(crate::asr::whisper::ZENMUX_DEFAULT_MODEL), "xiaomi-mimo-asr" => Some(crate::asr::mimo::DEFAULT_MODEL), _ => None, @@ -492,6 +496,7 @@ pub fn default_llm_endpoint(provider_type: &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(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT), "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "minimax" => Some("https://api.minimaxi.com/v1"), @@ -511,6 +516,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL), "mimo" => Some("xiaomi/mimo-v2-flash"), "openrouterFree" => Some("qwen/qwen3-coder:free"), + "orcarouter" => Some("orcarouter/fusion-flash"), "alibabaCoding" => Some("qwen3-coder-plus"), "codingPlanX" => Some("gpt-5-mini"), "minimax" => Some("MiniMax-M3"), @@ -566,7 +572,7 @@ pub fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind { BAILIAN_PROVIDER_ID => ActiveAsrProviderKind::Bailian, QWEN3_REALTIME_PROVIDER_ID => ActiveAsrProviderKind::Qwen3Realtime, STEPFUN_REALTIME_PROVIDER_ID => ActiveAsrProviderKind::StepfunRealtime, - MIMO_PROVIDER_ID => ActiveAsrProviderKind::Mimo, + MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID => ActiveAsrProviderKind::Mimo, DASHSCOPE_MULTIMODAL_PROVIDER_ID => ActiveAsrProviderKind::DashScopeMultimodal, ELEVENLABS_PROVIDER_ID => ActiveAsrProviderKind::ElevenLabs, XFYUN_PROVIDER_ID => ActiveAsrProviderKind::Xfyun, @@ -588,7 +594,7 @@ pub fn is_stepfun_realtime_provider(id: &str) -> bool { } pub fn is_mimo_provider(id: &str) -> bool { - id == MIMO_PROVIDER_ID + matches!(id, MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID) } pub fn is_dashscope_multimodal_provider(id: &str) -> bool { diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index a4d1c2d4e..a3bbd298b 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -642,11 +642,25 @@ async fn fetch_models( .endpoint .as_deref() .filter(|value| !value.trim().is_empty()) - .or_else(|| default_llm_endpoint(&resolved.provider_type)) - .or_else(|| default_omni_endpoint(&resolved.provider_type)) + .or_else(|| match resolved.kind { + ProviderKind::Asr => default_asr_endpoint(&resolved.provider_type), + ProviderKind::Llm => default_llm_endpoint(&resolved.provider_type), + ProviderKind::Omni => default_omni_endpoint(&resolved.provider_type), + }) .ok_or_else(|| provider_error("provider endpoint is not configured"))?; let url = models_url(endpoint)?; let is_gemini = resolved.provider_type == "gemini"; + let orcarouter_filter = (resolved.provider_type == "orcarouter").then(|| { + let endpoint_type = match (resolved.kind, resolved.protocol.format) { + (ProviderKind::Llm, LlmRequestFormat::Responses) => "openai-response", + (ProviderKind::Llm, LlmRequestFormat::Messages) => "anthropic", + _ => "openai", + }; + OrcaRouterCatalogFilter { + endpoint_type, + kind: resolved.kind, + } + }); let mut request_headers = Vec::new(); if let Some(api_key) = resolved .api_key @@ -655,11 +669,14 @@ async fn fetch_models( { if is_gemini { request_headers.push(("x-goog-api-key".to_string(), api_key.to_string())); + } else if orcarouter_filter.is_some() { + request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); } else { request_headers.extend(resolved.protocol.format.headers(api_key)); } } - if resolved.protocol.format == LlmRequestFormat::Messages + if orcarouter_filter.is_none() + && resolved.protocol.format == LlmRequestFormat::Messages && !request_headers .iter() .any(|(name, _)| name == "anthropic-version") @@ -692,10 +709,20 @@ async fn fetch_models( if response.body.len() > MODEL_LIST_MAX_BYTES { return Err(provider_error("provider model response is too large")); } - parse_model_list(&response.body, is_gemini) + parse_model_list(&response.body, is_gemini, orcarouter_filter) +} + +#[derive(Clone, Copy)] +struct OrcaRouterCatalogFilter { + endpoint_type: &'static str, + kind: ProviderKind, } -fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, BackendError> { +fn parse_model_list( + body: &[u8], + is_gemini: bool, + orcarouter_filter: Option, +) -> Result, BackendError> { let value: serde_json::Value = serde_json::from_slice(body) .map_err(|_| provider_error("provider model response is invalid JSON"))?; let models = if is_gemini { @@ -729,9 +756,37 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, Backend .and_then(serde_json::Value::as_array) .ok_or_else(|| provider_error("provider model response is missing data"))? .iter() + .filter(|item| { + orcarouter_filter.is_none_or(|filter| { + let supports_endpoint = item + .get("supported_endpoint_types") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| { + types + .iter() + .any(|value| value.as_str() == Some(filter.endpoint_type)) + }); + let supports_audio = filter.kind != ProviderKind::Asr + || item + .pointer("/architecture/input_modalities") + .and_then(serde_json::Value::as_array) + .is_some_and(|modalities| { + modalities + .iter() + .any(|value| value.as_str() == Some("audio")) + }); + supports_endpoint && supports_audio + }) + }) .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str)) .map(str::trim) .filter(|name| !name.is_empty()) + .filter(|name| { + if !orcarouter_filter.is_some_and(|filter| filter.kind == ProviderKind::Asr) { + return true; + } + name.to_ascii_lowercase().starts_with("google/gemini") + }) .map(str::to_string) .collect::>() }; @@ -1282,6 +1337,7 @@ mod tests { let models = parse_model_list( br#"{"data":[{"id":"gpt-z"},{"id":""},{"id":"gpt-a"},{"id":"gpt-z"}]}"#, false, + None, ) .unwrap(); assert_eq!(models, vec!["gpt-a", "gpt-z"]); @@ -1292,6 +1348,7 @@ mod tests { let models = parse_model_list( br#"{"models":[{"name":"models/gemini-z","supportedGenerationMethods":["generateContent"]},{"name":"models/embedding","supportedGenerationMethods":["embedContent"]},{"name":"gemini-a"}]}"#, true, + None, ) .unwrap(); assert_eq!(models, vec!["gemini-a", "gemini-z"]); @@ -1299,11 +1356,159 @@ mod tests { #[test] fn invalid_model_response_is_a_provider_error_without_body() { - let error = parse_model_list(br#"{"error":"secret-key"}"#, false).unwrap_err(); + let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None).unwrap_err(); assert_eq!(error.code, BackendErrorCode::Provider); assert!(!format!("{error:?}").contains("secret-key")); } + #[tokio::test] + async fn orcarouter_catalog_uses_core_channel_defaults_and_filters_capabilities() { + let catalog = r#"{"data":[ + {"id":"orcarouter/fusion-flash","supported_endpoint_types":["openai","anthropic"]}, + {"id":"google/gemini-2.5-flash","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text","audio"]}}, + {"id":"google/gemini-2.5-flash","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text","audio"]}}, + {"id":"google/gemini-image","supported_endpoint_types":["openai-image"]}, + {"id":"google/gemini-tts","supported_endpoint_types":["openai"],"architecture":{"input_modalities":["text"]}}, + {"id":"google/gemini-unknown","supported_endpoint_types":["openai"]}, + {"id":"openai/embedding","supported_endpoint_types":["embeddings"]}, + {"id":"legacy/chat"} + ]}"#; + for (kind, channel_kind, key_account, expected) in [ + (ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), + (ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash"]), + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response(200, catalog.as_bytes().to_vec()); + let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); + let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; + assert!(service.list_models(request.clone()).await.is_err()); + let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; + credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); + let result = service.list_models(request).await.unwrap(); + assert_eq!(result.models, expected); + let requests = transport.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].url, "https://api.orcarouter.ai/v1/models"); + } + } + + #[tokio::test] + async fn orcarouter_catalog_matches_the_selected_llm_protocol() { + let catalog = br#"{"data":[ + {"id":"model/chat","supported_endpoint_types":["openai"]}, + {"id":"model/responses","supported_endpoint_types":["openai-response"]}, + {"id":"model/messages","supported_endpoint_types":["anthropic"]}, + {"id":"model/all","supported_endpoint_types":["openai","openai-response","anthropic"]}, + {"id":"model/unknown"} + ]}"#; + for (format, expected) in [ + ("chat_completions", vec!["model/all", "model/chat"]), + ("responses", vec!["model/all", "model/responses"]), + ("messages", vec!["model/all", "model/messages"]), + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "orcarouter", + &[ + (LLM_API_KEY_ACCOUNT, "fixture-key"), + (crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, format), + ], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response(200, catalog.to_vec()); + let service = ProviderService::new_with_transport( + credentials, + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + + let result = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + + assert_eq!(result.models, expected, "request format: {format}"); + assert_eq!( + transport.requests()[0].headers, + vec![( + "Authorization".to_string(), + "Bearer fixture-key".to_string() + )], + "OrcaRouter /models always uses bearer authentication" + ); + } + } + + #[tokio::test] + async fn custom_asr_endpoint_does_not_enable_orcarouter_catalog_rules() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Asr, + "openai-compatible", + &[ + (ASR_ENDPOINT_ACCOUNT, "https://api.orcarouter.ai/v1"), + (ASR_MODEL_ACCOUNT, "whisper-1"), + ], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response( + 200, + br#"{"data":[{"id":"google/gemini-audio"},{"id":"openai/whisper-1"}]}"#.to_vec(), + ); + let service = ProviderService::new_with_transport( + credentials, + Arc::new(crate::TokioTaskSpawner), + transport, + ); + + let result = service + .list_models(ProviderRequest { + kind: ProviderKind::Asr, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + + assert_eq!( + result.models, + vec!["google/gemini-audio", "openai/whisper-1"] + ); + } + + #[tokio::test] + async fn orcarouter_validation_uses_shared_audio_chat_transcription() { + let (endpoint, request) = spawn_http_response("200 OK", "application/json", + r#"{"choices":[{"message":{"content":"transcript"}}]}"#); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ + (ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + assert!(request.starts_with("POST /v1/chat/completions ")); + let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); + let content = &body["messages"][0]["content"]; + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["input_audio"]["format"], "wav"); + assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); + } + async fn service_with_fake_transport() -> (ProviderService, Arc, String) { let credentials = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 84f73c65f..01bb432cd 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -19,6 +19,7 @@ import { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -48,6 +49,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 +85,18 @@ 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 listboxId = useId(); 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,7 +107,18 @@ 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 highlightedOptionId = highlight >= 0 + ? `${listboxId}-option-${highlight}` + : undefined; const positionPopover = useCallback(() => { const trigger = triggerRef.current; @@ -157,9 +179,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 +222,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 +237,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 +257,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 +271,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 +308,37 @@ export function SelectLite({ } }; + const moveFocusFromTrigger = (backward: boolean) => { + const trigger = triggerRef.current; + if (!trigger) return; + const focusable = Array.from(document.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + )).filter(element => element.getClientRects().length > 0 && !popoverRef.current?.contains(element)); + const index = focusable.indexOf(trigger); + focusable[index + (backward ? -1 : 1)]?.focus(); + }; + + const handleSearchKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeMenu(); + triggerRef.current?.focus(); + } 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); + } else if (event.key === 'Tab') { + event.preventDefault(); + closeMenu(); + moveFocusFromTrigger(event.shiftKey); + } + }; + const triggerStyle: CSSProperties = { ...DEFAULT_TRIGGER_STYLE, ...style, @@ -277,6 +355,8 @@ export function SelectLite({ role="combobox" aria-haspopup="listbox" aria-expanded={open} + aria-controls={open ? listboxId : undefined} + aria-activedescendant={open && !searchable ? highlightedOptionId : undefined} aria-disabled={disabled} aria-label={ariaLabel} disabled={disabled} @@ -305,7 +385,6 @@ export function SelectLite({ {open && anchor && createPortal(
- {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} + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={highlightedOptionId} + 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 700a94adc..43e30e48a 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)', @@ -1081,6 +1082,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 606c5b40f..68dd94315 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)', @@ -1083,6 +1084,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 0ffc6a580..8ffe7000b 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)', @@ -1083,6 +1084,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 b6d39c9ca..22496dcca 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)', @@ -1079,6 +1080,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 1c7d5c845..71b4cacd4 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)', @@ -1081,6 +1082,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 c039153a4..183426de6 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,7 +1,8 @@ import type { CredentialsStatus } from "../types" -import { invokeOrMock } from "./shared" +import { invokeOrMock, isTauri } from "./shared" import { mockCredentialsStatus, mockCredentialValues } from "./mock-data" import { invalidateMockChannelTest } from "./channels" +import type { LlmRequestFormat } from "./providers" export interface ProviderCheckResult { ok: boolean @@ -11,6 +12,36 @@ export interface ProviderModelsResult { models: string[] } +interface OrcaRouterCatalogModel { + id?: string + supported_endpoint_types?: string[] + architecture?: { + input_modalities?: string[] + } +} + +export function filterOrcaRouterModels( + models: OrcaRouterCatalogModel[], + kind: "llm" | "asr", + requestFormat: LlmRequestFormat = "chat_completions", +): string[] { + const endpointType = kind === "asr" + ? "openai" + : { + chat_completions: "openai", + responses: "openai-response", + messages: "anthropic", + }[requestFormat] + return models + .filter(model => model.supported_endpoint_types?.includes(endpointType) === true) + .filter(model => kind === "llm" || ( + model.id?.toLowerCase().startsWith("google/gemini") === true + && model.architecture?.input_modalities?.includes("audio") === true + )) + .map(model => model.id?.trim() ?? "") + .filter(Boolean) +} + export function getCredentials(): Promise { return invokeOrMock( "get_credentials", @@ -68,10 +99,27 @@ export function validateProviderCredentials( })) } -export function listProviderModels( +export async function listProviderModels( kind: "llm" | "asr" | "omni", channelId?: string, + providerType?: string, ): Promise { + if (!isTauri && providerType === "orcarouter" && (kind === "llm" || kind === "asr")) { + const endpointAccount = kind === "llm" ? "ark.endpoint" : "asr.endpoint" + const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`) + if (endpoint && new URL(endpoint).hostname.toLowerCase() === "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?: OrcaRouterCatalogModel[] } + const storedFormat = mockCredentialValues.get(`${channelId ?? ''}:ark.request_format`) + const requestFormat: LlmRequestFormat = storedFormat === "responses" || storedFormat === "messages" + ? storedFormat + : "chat_completions" + return { models: filterOrcaRouterModels(payload.data ?? [], kind, requestFormat) } + } + } 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 349a7e49b..5c1001737 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -76,6 +76,14 @@ const mockChannels: Record = { order: 1, lastTest: null, }, + { + id: "orcarouter-asr", + name: "OrcaRouter-ASR", + providerType: "orcarouter", + enabled: false, + order: 2, + lastTest: null, + }, ], } @@ -153,7 +161,11 @@ 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 { @@ -171,7 +183,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 + }, ) } diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index e37cbcddf..492704038 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -1,4 +1,7 @@ -export const mockCredentialValues = new Map() +export const mockCredentialValues = new Map([ + ["orcarouter-asr:asr.endpoint", "https://api.orcarouter.ai/v1"], + ["orcarouter-asr:asr.model", "google/gemini-2.5-flash"], +]) import type { ActivityDay, CorrectionRule, diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 3db537eb8..8387f2849 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -29,15 +29,21 @@ export interface ProviderDescriptor { export function listProviderDescriptors(kind: ProviderKind): Promise { return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ ['opencode', 'opencode', 'chat_completions'], + ['orcarouter', 'orcarouter', 'chat_completions'], ['custom', 'customChatCompletions', 'chat_completions'], ['custom_responses', 'customResponses', 'responses'], ['custom_messages', 'customMessages', 'messages'], ].map(([providerType, labelKey, format]) => ({ kind, providerType, labelKey, - defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, - defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' : null, + defaultEndpoint: providerType === 'orcarouter' ? 'https://api.orcarouter.ai/v1' : providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, + defaultModel: providerType === 'orcarouter' ? 'orcarouter/fusion-flash' : providerType === 'opencode' ? 'deepseek-v4-flash' : null, authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], defaultRequestFormat: format as LlmRequestFormat, supportedRequestFormats: ['chat_completions', 'responses', 'messages'], - })) : []) + })) : kind === 'asr' ? [{ + kind, providerType: 'orcarouter', labelKey: 'orcarouter', + defaultEndpoint: 'https://api.orcarouter.ai/v1', defaultModel: 'google/gemini-2.5-flash', + authRequirement: 'api_key', validationProbe: 'asr_silence', staticModels: [], + defaultRequestFormat: null, supportedRequestFormats: [], + }] : []) } diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 389e1b241..b7731d07b 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -1895,7 +1895,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 6faee63aa..4c499e527 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', opencode: 'opencode', diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index 4e0a699ab..089f1aea1 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -1,6 +1,6 @@ import type { OS } from '../../components/WindowChrome'; import type { ProviderDescriptor } from '../../lib/ipc'; -import { presetsFor, shouldRecycleDraft } from './ChannelList'; +import { defaultChannelNameForProvider, presetsFor, shouldRecycleDraft } from './ChannelList'; const localProviders = [ 'local-qwen3', @@ -102,4 +102,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 d97d5ef26..b13b97d3a 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -103,6 +103,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, @@ -752,6 +758,11 @@ function ChannelModal({ if (!preset) return; const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; + if (next === 'orcarouter') { + if (preset.defaultEndpoint) await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); + if (preset.defaultModel) await setCredential(modelAccount, preset.defaultModel, channel.id); + return; + } if (preset.defaultEndpoint && !(await readCredential(endpointAccount, channel.id))?.trim()) { await setCredential(endpointAccount, preset.defaultEndpoint, channel.id); } @@ -771,6 +782,11 @@ function ChannelModal({ await setChannelProviderType(kind, channel.id, next); await fillProviderDefaults(next); setProviderType(next); + const defaultName = defaultChannelNameForProvider(next, name); + if (defaultName !== name) { + await renameChannel(kind, channel.id, defaultName); + setName(defaultName); + } await onChanged(); } catch (error) { console.error('[channels] change provider failed', error); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index 4397fdaa2..ed3b99dd6 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -32,7 +32,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut formats: LlmRequestFormat[]; onUserMutation: () => void; onBlockedChange: (account: string, blocked: boolean) => void; - onSaved?: () => void; + onSaved?: (changedAccounts: readonly Account[]) => void; }) { const { t } = useTranslation(); const [values, setValues] = useState(emptyValues); @@ -66,13 +66,14 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut writing.current = true; setSaving(true); setError(null); try { - for (const account of accounts) { - if (next[account] !== saved[account]) await setCredential(account, next[account], channelId); + const changedAccounts = accounts.filter(account => next[account] !== saved[account]); + for (const account of changedAccounts) { + await setCredential(account, next[account], channelId); } if (mounted.current) { setSaved(next); emitSaved('saved', t('common.saved')); - onSaved?.(); + onSaved?.(changedAccounts); } } catch { if (mounted.current) { setError('save'); emitSaved('failed', t('common.operationFailed')); } diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 7e027d7d7..88b964f16 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,6 +1,7 @@ -import { LLM_LABELS } from './ProvidersSection'; +import { LLM_LABELS, prioritizeOrcaRouterModels } from './ProvidersSection'; import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; +import { filterOrcaRouterModels } from '../../lib/ipc/asr-credentials'; const atlascloudPreset = LLM_LABELS.find(p => p.id === 'atlascloud'); if (LLM_LABELS.find(p => p.id === 'opencode')?.nameKey !== 'opencode') { @@ -39,3 +40,53 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [{ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optional_api_key') { throw new Error('Core provider descriptor must replace the browser fallback in the channel picker'); } + +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 protocolCatalog = [ + { id: 'model/chat', supported_endpoint_types: ['openai'] }, + { id: 'model/responses', supported_endpoint_types: ['openai-response'] }, + { id: 'model/messages', supported_endpoint_types: ['anthropic'] }, + { id: 'model/all', supported_endpoint_types: ['openai', 'openai-response', 'anthropic'] }, + { id: 'model/unknown' }, +]; +for (const [format, expected] of [ + ['chat_completions', ['model/chat', 'model/all']], + ['responses', ['model/responses', 'model/all']], + ['messages', ['model/messages', 'model/all']], +] as const) { + const actual = filterOrcaRouterModels(protocolCatalog, 'llm', format); + if (actual.join(',') !== expected.join(',')) { + throw new Error(`unexpected ${format} catalog: ${actual.join(',')}`); + } +} + +const asrCatalog = [ + { id: 'google/gemini-audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text', 'audio'] } }, + { id: 'google/gemini-text', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text'] } }, + { id: 'google/gemini-unknown', supported_endpoint_types: ['openai'] }, + { id: 'meta/audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['audio'] } }, +]; +if (filterOrcaRouterModels(asrCatalog, 'asr').join(',') !== 'google/gemini-audio') { + throw new Error('OrcaRouter ASR catalog must require declared Gemini audio input'); +} + +for (const labels of [LLM_LABELS, ASR_LABELS]) { + if (!labels.some(label => label.id === 'orcarouter' && label.nameKey === 'orcarouter')) { + throw new Error('OrcaRouter provider label is missing'); + } +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f50e2e734..a599b73e0 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -89,7 +89,7 @@ export const LLM_LABELS = [ ['ark', 'ark'], ['deepseek', 'deepseek'], ['siliconflow', 'siliconflow'], ['atlascloud', 'atlascloud'], ['openai', 'openai'], ['gemini', 'gemini'], ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], - ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], + ['openrouterFree', 'openrouterFree'], ['orcarouter', 'orcarouter'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], ['opencode', 'opencode'], ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], @@ -137,6 +137,7 @@ export function ChannelCredentialFields({ const layoutStack = conservative || baseLayoutStack; const [llmModelRevision, setLlmModelRevision] = useState(0); const [configRevision, setConfigRevision] = useState(0); + const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0); const [blockedFields, setBlockedFields] = useState>({}); const trackField = useCallback((account: string, blocked: boolean) => { setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked }); @@ -191,7 +192,12 @@ export function ChannelCredentialFields({ {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( + onBlockedChange={trackField} onSaved={changedAccounts => { + if (providerType === 'orcarouter' && changedAccounts.includes('ark.request_format')) { + setOrcarouterCatalogRevision(value => value + 1); + } + onTested?.(); + }} /> )} {codexOAuthSelected ? (
@@ -228,6 +234,12 @@ export function ChannelCredentialFields({ )} )} + {providerType === 'orcarouter' ? ( + } /> + ) : ( )} /> + )} setLlmModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} /> + onUserMutation={onUserMutation} showFetchModels={providerType !== 'orcarouter'} /> ); } @@ -339,6 +352,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, + onBlockedChange, +}: { + kind: 'llm' | 'asr'; + provider: string; + baseUrl: string; + defaultModel: string; + trailing?: ReactNode; + onUserMutation?: () => void; + onBlockedChange?: (account: string, blocked: boolean) => 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'; + + useEffect(() => { + onBlockedChange?.(modelAccount, status !== 'success'); + }, [modelAccount, status, onBlockedChange]); + + 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, 'orcarouter'), + ]); + 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, disabled = false }: { disabled?: boolean; 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 747accfd8..7a191f8a4 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -223,6 +223,7 @@ export const ASR_LABELS = [ { id: 'groq', nameKey: 'asrGroq' }, { id: 'whisper', nameKey: 'asrWhisper' }, { id: 'openrouter', nameKey: 'asrOpenrouter' }, + { id: 'orcarouter', nameKey: 'orcarouter' }, { id: 'zenmux', nameKey: 'asrZenmux' }, { id: 'openai-compatible', nameKey: 'asrOpenAiCompatible' }, { id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo' }, 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: {