Merge origin/beta into codex/ui-2.0-details(汇入 #1022/#1023/#1041/#1042,保留 2.0 渠道卡片结构并整合 OrcaRouter 目录选择)
This commit is contained in:
commit
0fa21daaf0
25 files changed
+970
-114
No files matched your search
@@ -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).
|
||||
|
||||
+2
-2
@@ -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))。
|
||||
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -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<RawTranscript> {
|
||||
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<String> {
|
||||
}
|
||||
|
||||
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!({
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
|
||||
| "mimo"
|
||||
| "cometapi"
|
||||
| "openrouterFree"
|
||||
| "orcarouter"
|
||||
| "alibabaCoding"
|
||||
| "codingPlanX"
|
||||
| "minimax"
|
||||
@@ -1878,7 +1879,9 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option<Th
|
||||
"alibabaCoding" => 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,
|
||||
@@ -3088,6 +3091,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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
fn parse_model_list(body: &[u8], is_gemini: bool) -> Result<Vec<String>, BackendError> {
|
||||
#[derive(Clone, Copy)]
|
||||
struct OrcaRouterCatalogFilter {
|
||||
endpoint_type: &'static str,
|
||||
kind: ProviderKind,
|
||||
}
|
||||
|
||||
fn parse_model_list(
|
||||
body: &[u8],
|
||||
is_gemini: bool,
|
||||
orcarouter_filter: Option<OrcaRouterCatalogFilter>,
|
||||
) -> Result<Vec<String>, 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<Vec<String>, 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::<Vec<_>>()
|
||||
};
|
||||
@@ -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<FakeProviderTransport>, String)
|
||||
{
|
||||
let credentials = Arc::new(InMemoryCredentialStore::default());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// SelectLite 在各平台提供一致的下拉交互、键盘操作和浮层定位。
|
||||
// SelectLite — 三平台统一的自定义下拉,替代 native <select>,避开 Win32 ComboBox
|
||||
// 直角丑框(issue #418)以及 WKWebView 上原生 NSPopUpButton 的视觉割裂。
|
||||
//
|
||||
// 设计:
|
||||
// - 触发器是 button(chevron + 当前值),样式可被 `style` 覆盖
|
||||
@@ -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 = {
|
||||
@@ -57,7 +62,7 @@ const DEFAULT_TRIGGER_STYLE: CSSProperties = {
|
||||
gap: 8,
|
||||
padding: '0 10px',
|
||||
height: 32,
|
||||
fontSize: 13.5,
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'inherit',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
@@ -80,21 +85,40 @@ export function SelectLite({
|
||||
style,
|
||||
ariaLabel,
|
||||
onOpenChange,
|
||||
searchable = false,
|
||||
searchPlaceholder = 'Search…',
|
||||
emptyMessage = 'No matching options',
|
||||
}: SelectLiteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const listboxId = useId();
|
||||
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [highlight, setHighlight] = useState<number>(-1);
|
||||
const [query, setQuery] = useState('');
|
||||
const listboxId = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null);
|
||||
// popoverMounted 让 useLayoutEffect 在 popover 实际进入 DOM 后再触发一次 positionPopover,
|
||||
// 而且整轮发生在 paint 之前——见下方 useLayoutEffect 注释。
|
||||
const [popoverMounted, setPopoverMounted] = useState(false);
|
||||
|
||||
const selected = useMemo(() => options.find((opt) => opt.value === value), [options, value]);
|
||||
const selected = useMemo(
|
||||
() => 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;
|
||||
@@ -155,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;
|
||||
@@ -175,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 });
|
||||
@@ -190,12 +237,13 @@ export function SelectLite({
|
||||
};
|
||||
// closeMenu 是稳定引用(无 React state 依赖),不放 deps。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
}, [open, positionPopover]);
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) return;
|
||||
const initial = options.findIndex((opt) => opt.value === value && !opt.disabled);
|
||||
setHighlight(initial >= 0 ? initial : options.findIndex((opt) => !opt.disabled));
|
||||
setQuery('');
|
||||
const initial = options.findIndex(opt => opt.value === value && !opt.disabled);
|
||||
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
|
||||
setLeaving(false);
|
||||
setOpen(true);
|
||||
onOpenChange?.(true);
|
||||
@@ -209,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();
|
||||
@@ -222,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;
|
||||
}
|
||||
@@ -236,12 +285,7 @@ export function SelectLite({
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
if (
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'Enter' ||
|
||||
event.key === ' '
|
||||
) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
@@ -264,6 +308,37 @@ export function SelectLite({
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocusFromTrigger = (backward: boolean) => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const focusable = Array.from(document.querySelectorAll<HTMLElement>(
|
||||
'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<HTMLInputElement>) => {
|
||||
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,
|
||||
@@ -281,8 +356,7 @@ export function SelectLite({
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-owns={open ? listboxId : undefined}
|
||||
aria-activedescendant={open && highlight >= 0 ? `${listboxId}-${highlight}` : undefined}
|
||||
aria-activedescendant={open && !searchable ? highlightedOptionId : undefined}
|
||||
aria-disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
@@ -308,52 +382,108 @@ export function SelectLite({
|
||||
</span>
|
||||
<Icon name="chevDown" size={11} />
|
||||
</button>
|
||||
{open &&
|
||||
anchor &&
|
||||
createPortal(
|
||||
{open && anchor && createPortal(
|
||||
<div
|
||||
ref={setPopoverRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchor.left,
|
||||
top: anchor.top,
|
||||
// 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出
|
||||
// trigger 范围;长 label 走 textOverflow:ellipsis 截断。
|
||||
width: anchor.width,
|
||||
maxHeight: searchable ? 320 : 280,
|
||||
overflow: 'hidden',
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid var(--ol-select-popover-border)',
|
||||
background: 'var(--ol-select-popover-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--ol-select-popover-shadow)',
|
||||
zIndex: 9999,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
animation: leaving
|
||||
? 'ol-select-pop-out .14s cubic-bezier(.4,.0,.7,.2) forwards'
|
||||
: 'ol-select-pop .14s var(--ol-motion-quick) both',
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
>
|
||||
{searchable && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
margin: '1px 1px 4px',
|
||||
padding: '0 9px',
|
||||
height: 34,
|
||||
borderRadius: 7,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-select-trigger-bg)',
|
||||
color: 'var(--ol-ink-4)',
|
||||
}}
|
||||
>
|
||||
<Icon name="search" size={13} />
|
||||
<input
|
||||
ref={searchRef}
|
||||
role="combobox"
|
||||
value={query}
|
||||
onChange={event => 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',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 10.5, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{filteredOptions.length}/{options.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={setPopoverRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchor.left,
|
||||
top: anchor.top,
|
||||
// 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出
|
||||
// trigger 范围;长 label 走 textOverflow:ellipsis 截断。
|
||||
width: anchor.width,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
border: '0.5px solid var(--ol-select-popover-border)',
|
||||
background: 'var(--ol-select-popover-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--ol-select-popover-shadow)',
|
||||
zIndex: 9999,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 13.5,
|
||||
animation: leaving
|
||||
? 'ol-select-pop-out .14s cubic-bezier(.4,.0,.7,.2) forwards'
|
||||
: 'ol-select-pop .14s var(--ol-motion-quick) both',
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
{filteredOptions.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '18px 10px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--ol-ink-4)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
{filteredOptions.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
const isHighlighted = index === highlight;
|
||||
return (
|
||||
<div
|
||||
key={option.value || `__opt_${index}`}
|
||||
id={`${listboxId}-${index}`}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
data-option-index={index}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
onMouseEnter={() => !option.disabled && setHighlight(index)}
|
||||
onMouseDown={(event) => {
|
||||
onMouseDown={event => {
|
||||
event.preventDefault();
|
||||
selectIndex(index);
|
||||
}}
|
||||
@@ -365,10 +495,9 @@ export function SelectLite({
|
||||
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',
|
||||
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',
|
||||
@@ -377,9 +506,7 @@ export function SelectLite({
|
||||
transition: 'background 0.10s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{option.label}
|
||||
</span>
|
||||
{option.trailing}
|
||||
@@ -387,9 +514,10 @@ export function SelectLite({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1252,6 +1252,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)',
|
||||
@@ -1340,6 +1341,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',
|
||||
|
||||
@@ -1249,6 +1249,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)',
|
||||
@@ -1337,6 +1338,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',
|
||||
|
||||
@@ -1241,6 +1241,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)',
|
||||
@@ -1328,6 +1329,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',
|
||||
|
||||
@@ -1193,6 +1193,7 @@ export const zhCN = {
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免费模型)',
|
||||
orcarouter: 'OrcaRouter',
|
||||
alibabaCoding: '阿里云 Coding Plan',
|
||||
codingPlanX: 'CodingPlanX',
|
||||
minimax: 'MiniMax(M3)',
|
||||
@@ -1277,6 +1278,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',
|
||||
|
||||
@@ -1195,6 +1195,7 @@ export const zhTW: typeof zhCN = {
|
||||
mimo: '小米 MiMo',
|
||||
cometapi: 'CometAPI',
|
||||
openrouterFree: 'OpenRouter(免費模型)',
|
||||
orcarouter: 'OrcaRouter',
|
||||
alibabaCoding: '阿里雲 Coding Plan',
|
||||
codingPlanX: 'CodingPlanX',
|
||||
minimax: 'MiniMax(M3)',
|
||||
@@ -1279,6 +1280,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',
|
||||
|
||||
@@ -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<CredentialsStatus> {
|
||||
return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus);
|
||||
}
|
||||
@@ -52,10 +83,27 @@ export function validateProviderCredentials(
|
||||
}));
|
||||
}
|
||||
|
||||
export function listProviderModels(
|
||||
export async function listProviderModels(
|
||||
kind: 'llm' | 'asr' | 'omni',
|
||||
channelId?: string,
|
||||
providerType?: string,
|
||||
): Promise<ProviderModelsResult> {
|
||||
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' ? ['gpt-4o', 'deepseek-v4-flash', 'deepseek-v4-pro'] : ['whisper-1'],
|
||||
}));
|
||||
|
||||
@@ -76,6 +76,14 @@ const mockChannels: Record<ChannelKind, Channel[]> = {
|
||||
order: 1,
|
||||
lastTest: null,
|
||||
},
|
||||
{
|
||||
id: 'orcarouter-asr',
|
||||
name: 'OrcaRouter-ASR',
|
||||
providerType: 'orcarouter',
|
||||
enabled: false,
|
||||
order: 2,
|
||||
lastTest: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -146,7 +154,11 @@ export function deleteChannelIfBlank(kind: ChannelKind, id: string): Promise<boo
|
||||
}
|
||||
|
||||
export function renameChannel(kind: ChannelKind, id: string, name: string): Promise<void> {
|
||||
return invokeOrMock('rename_channel', { kind, id, name }, () => undefined);
|
||||
return invokeOrMock('rename_channel', { kind, id, name }, () => {
|
||||
const channel = mockChannels[kind].find((channel) => channel.id === id);
|
||||
if (channel) channel.name = name;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
|
||||
@@ -158,7 +170,11 @@ export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export function setChannelEnabled(kind: ChannelKind, id: string, enabled: boolean): Promise<void> {
|
||||
return invokeOrMock('set_channel_enabled', { kind, id, enabled }, () => undefined);
|
||||
return invokeOrMock('set_channel_enabled', { kind, id, enabled }, () => {
|
||||
const channel = mockChannels[kind].find((channel) => channel.id === id);
|
||||
if (channel) channel.enabled = enabled;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** ids 是拖拽后的完整顺序;后端会把未提及的渠道排到末尾。 */
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const mockCredentialValues = new Map<string, string>();
|
||||
export const mockCredentialValues = new Map<string, string>([
|
||||
['orcarouter-asr:asr.endpoint', 'https://api.orcarouter.ai/v1'],
|
||||
['orcarouter-asr:asr.model', 'google/gemini-2.5-flash'],
|
||||
]);
|
||||
import type {
|
||||
ActivityDay,
|
||||
CorrectionRule,
|
||||
|
||||
@@ -157,6 +157,18 @@
|
||||
"supportedRequestFormats": [],
|
||||
"validationProbe": "asr_silence"
|
||||
},
|
||||
{
|
||||
"authRequirement": "api_key",
|
||||
"defaultEndpoint": "https://api.orcarouter.ai/v1",
|
||||
"defaultModel": "google/gemini-2.5-flash",
|
||||
"defaultRequestFormat": null,
|
||||
"kind": "asr",
|
||||
"labelKey": "orcarouter",
|
||||
"providerType": "orcarouter",
|
||||
"staticModels": [],
|
||||
"supportedRequestFormats": [],
|
||||
"validationProbe": "asr_silence"
|
||||
},
|
||||
{
|
||||
"authRequirement": "api_key",
|
||||
"defaultEndpoint": "https://zenmux.ai/api/v1",
|
||||
@@ -450,6 +462,22 @@
|
||||
],
|
||||
"validationProbe": "llm_text"
|
||||
},
|
||||
{
|
||||
"authRequirement": "api_key_unless_custom_endpoint",
|
||||
"defaultEndpoint": "https://api.orcarouter.ai/v1",
|
||||
"defaultModel": "orcarouter/fusion-flash",
|
||||
"defaultRequestFormat": "chat_completions",
|
||||
"kind": "llm",
|
||||
"labelKey": "orcarouter",
|
||||
"providerType": "orcarouter",
|
||||
"staticModels": [],
|
||||
"supportedRequestFormats": [
|
||||
"chat_completions",
|
||||
"responses",
|
||||
"messages"
|
||||
],
|
||||
"validationProbe": "llm_text"
|
||||
},
|
||||
{
|
||||
"authRequirement": "api_key_unless_custom_endpoint",
|
||||
"defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
|
||||
@@ -59,6 +59,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
|
||||
mimo: 'mimo',
|
||||
cometapi: 'cometapi',
|
||||
openrouterFree: 'openrouterFree',
|
||||
orcarouter: 'orcarouter',
|
||||
alibabaCoding: 'alibabaCoding',
|
||||
codingPlanX: 'codingPlanX',
|
||||
minimax: 'minimax',
|
||||
|
||||
@@ -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');
|
||||
@@ -115,6 +115,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,
|
||||
@@ -1004,6 +1010,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);
|
||||
}
|
||||
@@ -1023,6 +1034,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);
|
||||
|
||||
@@ -61,7 +61,7 @@ export function LlmProtocolFields({
|
||||
formats: LlmRequestFormat[];
|
||||
onUserMutation: () => void;
|
||||
onBlockedChange: (account: string, blocked: boolean) => void;
|
||||
onSaved?: () => void;
|
||||
onSaved?: (changedAccounts?: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<ProtocolValues>(emptyValues);
|
||||
@@ -107,14 +107,14 @@ export function LlmProtocolFields({
|
||||
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) {
|
||||
|
||||
@@ -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') {
|
||||
@@ -43,3 +44,53 @@ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optio
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ export const LLM_LABELS = [
|
||||
['mimo', 'mimo'],
|
||||
['cometapi', 'cometapi'],
|
||||
['openrouterFree', 'openrouterFree'],
|
||||
['orcarouter', 'orcarouter'],
|
||||
['alibabaCoding', 'alibabaCoding'],
|
||||
['codingPlanX', 'codingPlanX'],
|
||||
['minimax', 'minimax'],
|
||||
@@ -187,6 +188,7 @@ export function ChannelCredentialFields({
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [configRevision, setConfigRevision] = useState(0);
|
||||
const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0);
|
||||
const [blockedFields, setBlockedFields] = useState<Record<string, boolean>>({});
|
||||
const trackField = useCallback((account: string, blocked: boolean) => {
|
||||
setBlockedFields((previous) =>
|
||||
@@ -252,7 +254,12 @@ export function ChannelCredentialFields({
|
||||
formats={descriptor.supportedRequestFormats}
|
||||
onUserMutation={onLlmMutation}
|
||||
onBlockedChange={trackField}
|
||||
onSaved={onTested}
|
||||
onSaved={(changedAccounts) => {
|
||||
if (providerType === 'orcarouter' && changedAccounts?.includes('ark.request_format')) {
|
||||
setOrcarouterCatalogRevision((value) => value + 1);
|
||||
}
|
||||
onTested?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{codexOAuthSelected ? (
|
||||
@@ -312,6 +319,23 @@ export function ChannelCredentialFields({
|
||||
description={t('settings.channels.modelHint')}
|
||||
/>
|
||||
</div>
|
||||
{providerType === 'orcarouter' ? (
|
||||
<CatalogModelField
|
||||
key={`${channelId}:catalog:${orcarouterCatalogRevision}`}
|
||||
kind="llm"
|
||||
provider={channelId}
|
||||
baseUrl={defaultEndpoint ?? ''}
|
||||
defaultModel={defaultModel ?? ''}
|
||||
onUserMutation={onLlmMutation}
|
||||
onBlockedChange={trackField}
|
||||
trailing={
|
||||
<LlmThinkingToggle
|
||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||
onToggle={onLlmThinkingToggle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CredentialField
|
||||
key={`${channelId}:model:${llmModelRevision}`}
|
||||
label={t('settings.providers.modelLabel')}
|
||||
@@ -329,6 +353,7 @@ export function ChannelCredentialFields({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
|
||||
<CredentialField
|
||||
key={`${channelId}:temperature`}
|
||||
@@ -353,6 +378,7 @@ export function ChannelCredentialFields({
|
||||
onModelSelected={() => setLlmModelRevision((v) => v + 1)}
|
||||
onTested={onTested}
|
||||
onUserMutation={onUserMutation}
|
||||
showFetchModels={providerType !== 'orcarouter'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -504,6 +530,29 @@ export function ChannelCredentialFields({
|
||||
);
|
||||
}
|
||||
|
||||
if (providerType === 'orcarouter') {
|
||||
return (
|
||||
<>
|
||||
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.apiKeyLabel')}
|
||||
account="asr.api_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
|
||||
<CredentialField key={`${channelId}:endpoint`} label={t('settings.providers.baseUrlLabel')}
|
||||
account="asr.endpoint" provider={channelId}
|
||||
placeholder={defaultEndpoint ?? undefined} defaultValue={defaultEndpoint ?? undefined}
|
||||
onUserMutation={onUserMutation} />
|
||||
<CatalogModelField
|
||||
kind="asr"
|
||||
provider={channelId}
|
||||
baseUrl={defaultEndpoint ?? ''}
|
||||
defaultModel={defaultModel ?? ''}
|
||||
onUserMutation={onUserMutation}
|
||||
/>
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" provider={channelId}
|
||||
onModelSelected={() => setAsrModelRevision(v => v + 1)} onTested={onTested}
|
||||
onUserMutation={onUserMutation} showFetchModels={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CredentialField
|
||||
@@ -806,6 +855,178 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) {
|
||||
|
||||
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
|
||||
|
||||
/**
|
||||
* OrcaRouter exposes a large, changing catalog. Keep its own router models at
|
||||
* the top, then sort the remaining vendor/model ids for predictable scanning.
|
||||
*/
|
||||
export function prioritizeOrcaRouterModels(models: string[]): string[] {
|
||||
return [...models].sort((left, right) => {
|
||||
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<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [status, setStatus] = useState<ProviderToolStatus>('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 (
|
||||
<SettingRow label={t('settings.providers.modelLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
|
||||
<SelectLite
|
||||
value={selectedModel}
|
||||
onChange={(model) => 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)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
onUserMutation?.();
|
||||
void loadModels(false);
|
||||
}}
|
||||
title={t('common.refresh')}
|
||||
aria-label={t('common.refresh')}
|
||||
style={iconBtnStyle}
|
||||
disabled={status === 'loading'}
|
||||
>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
{trailing}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'success' ? 'var(--ol-ok)' : 'var(--ol-ink-4)', lineHeight: 1.4 }}>
|
||||
{message}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.4 }}>
|
||||
{t(kind === 'asr'
|
||||
? 'settings.providers.orcarouterAsrCatalogHint'
|
||||
: 'settings.providers.orcarouterCatalogHint')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderTools({
|
||||
kind,
|
||||
modelAccount,
|
||||
|
||||
@@ -231,6 +231,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' },
|
||||
|
||||
@@ -30,6 +30,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: {
|
||||
|
||||
Reference in new issue
Block a user