feat(app): finalize 2.0 channel settings UI, 8-language alignment and cloud-sync groundwork

- 渠道设置界面:ChannelList/LlmProtocolFields/ProvidersSection 渠道级协议与模型配置
- 8 种界面语言 1711 项文案对齐(de/en/es/fr/ja/ko/zh-CN/zh-TW)
- Core 协议层 llm_protocol 与 provider_service 配套、云同步准备
- linux-egui 后端与 Android updater 配套调整
This commit is contained in:
sim committed 2026-09-09 13:04:10 +08:00
commit 1d29f8a6cd
53 files changed
+3250 -410

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo
- **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).
- **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, 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).
+1 -1
View File
@@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
- **切换式与按住说话(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**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。
- **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。
- **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
+1 -1
View File
@@ -65,7 +65,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装
`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。
主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮采用左右/顶部 16px 的对齐基准,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。
主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。
Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。
@@ -3486,7 +3486,40 @@ impl OpenLessBackend {
key: CredentialKey,
value: SecretValue,
) -> Result<CredentialsStatus, BackendError> {
if key.namespace == crate::CredentialNamespace::Llm
&& crate::llm_protocol::CONFIG_ACCOUNTS.contains(&key.account.as_str())
{
crate::llm_protocol::LlmProtocolConfig::default()
.apply(&key.account, value.expose_secret())?;
}
let invalidate = if key.namespace == crate::CredentialNamespace::Llm {
let id = match &key.provider_id {
Some(id) => id.clone(),
None => {
self.deps
.credential_store
.active_provider(crate::ProviderSlot::Llm)
.await?
}
};
self.list_channels(ChannelKind::Llm)
.await?
.into_iter()
.find(|channel| channel.id == id)
.map(|_| id)
} else {
None
};
self.deps.credential_store.write(key, value).await?;
if let Some(id) = invalidate {
self.deps
.credential_store
.mutate_channel(ChannelMutation::InvalidateTest {
kind: ChannelKind::Llm,
id,
})
.await?;
}
self.refresh_and_publish_credentials().await
}
@@ -3533,13 +3566,51 @@ impl OpenLessBackend {
id: String,
provider_type: String,
) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ())
let provider_type = provider_type.trim().to_string();
if provider_type.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"provider type must not be blank",
));
}
let previous = self
.list_channels(kind)
.await?
.into_iter()
.find(|channel| channel.id == id)
.ok_or_else(|| {
BackendError::new(BackendErrorCode::InvalidArgument, "unknown channel")
})?;
let key = CredentialKey::new(
crate::CredentialNamespace::Llm,
Some(id.clone()),
crate::llm_protocol::REQUEST_FORMAT_ACCOUNT,
)?;
let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type;
let old_format = if reset {
let value = self.deps.credential_store.read(key.clone()).await?;
self.deps.credential_store.remove(key.clone()).await?;
value
} else {
None
};
let result = self
.deps
.credential_store
.mutate_channel(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ());
if result.is_err() {
if let Some(value) = old_format {
self.deps.credential_store.write(key, value).await?;
}
}
result?;
self.refresh_and_publish_credentials().await.map(|_| ())
}
pub async fn delete_channel_if_blank(
@@ -3621,6 +3692,12 @@ impl OpenLessBackend {
.map(|_| ())
}
pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind })
.await
.map(|_| ())
}
pub async fn active_provider(&self, slot: ProviderSlot) -> Result<String, BackendError> {
self.deps.credential_store.active_provider(slot).await
}
@@ -8352,6 +8429,142 @@ mod tests {
));
}
#[tokio::test]
async fn invalidating_llm_tests_preserves_asr_test_results() {
let (backend, _) = backend();
for (kind, provider, name) in [
(ChannelKind::Llm, "custom", "first"),
(ChannelKind::Llm, "custom_messages", "second"),
(ChannelKind::Asr, "openai-compatible", "asr"),
] {
let id = backend
.create_channel(kind, provider.into(), name.into())
.await
.unwrap();
backend
.record_channel_test(kind, id, true, Some(1), None)
.await
.unwrap();
}
backend
.invalidate_channel_tests(ChannelKind::Llm)
.await
.unwrap();
assert!(backend
.list_channels(ChannelKind::Llm)
.await
.unwrap()
.iter()
.all(|channel| channel.last_test.is_none()));
assert!(backend.list_channels(ChannelKind::Asr).await.unwrap()[0]
.last_test
.is_some());
}
#[tokio::test]
async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() {
use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue};
use crate::llm_protocol::*;
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: std::env::temp_dir()
.join(format!("openless-protocol-{}", uuid::Uuid::new_v4())),
..BackendConfig::default()
},
BackendDependencies {
host_actions: Arc::new(FakeHost::default()),
text_inserter: Arc::new(FakeInserter),
dictation_engine: Arc::new(FakeEngine),
task_spawner: Arc::new(TokioTaskSpawner),
credential_store: Arc::new(InMemoryCredentialStore::default()),
services: crate::domains::BackendServices::unsupported(),
local_asr_runtime: None,
marketplace_config: None,
selection_runtime: None,
selection_polisher: None,
qa_runtime: None,
},
)
.unwrap();
let id = backend
.create_channel(ChannelKind::Llm, "custom".into(), "test".into())
.await
.unwrap();
let key = |account: &str| {
CredentialKey::new(CredentialNamespace::Llm, Some(id.clone()), account).unwrap()
};
backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages"))
.await
.unwrap();
backend
.set_credential(
key(crate::credentials::LLM_API_KEY_ACCOUNT),
SecretValue::new("fixture-key"),
)
.await
.unwrap();
backend
.record_channel_test(ChannelKind::Llm, id.clone(), true, Some(1), None)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_some());
backend
.set_credential(
key(crate::credentials::LLM_MODEL_ACCOUNT),
SecretValue::new("new-model"),
)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_none());
assert_eq!(
backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"messages"
);
assert!(backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("invalid"))
.await
.is_err());
backend
.set_channel_provider_type(ChannelKind::Llm, id.clone(), "custom_responses".into())
.await
.unwrap();
assert!(backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.is_none());
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"fixture-key"
);
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_MODEL_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"new-model"
);
}
#[tokio::test]
async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() {
let (backend, _) = backend();
@@ -76,7 +76,10 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"codingPlanX",
"minimax",
"stepfun",
"opencode",
"custom",
"custom_responses",
"custom_messages",
];
pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"];
@@ -1122,12 +1125,9 @@ async fn build_cloud_polisher_provider(
return Ok(CloudPolisherProvider::Gemini(provider));
}
let base_url = endpoint
.trim()
.trim_end_matches('/')
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let protocol =
crate::llm_protocol::LlmProtocolConfig::load(credentials, channel_id, provider_type)
.await?;
let temperature = read_channel_credential(
credentials,
CredentialNamespace::Llm,
@@ -1153,10 +1153,11 @@ async fn build_cloud_polisher_provider(
let config = crate::polish::OpenAICompatibleConfig::new(
provider_type,
"OpenLess LLM",
base_url,
endpoint,
api_key,
model,
)
.with_protocol(protocol)
.with_thinking_enabled(context.polish.llm_thinking_enabled)
.with_temperature(crate::polish::openai_compatible_temperature_for_provider(
provider_type,
@@ -1970,14 +1971,22 @@ mod tests {
}
}
async fn assert_translation_streams_before_completion(
async fn assert_translation_streams_for_protocol(
gemini: bool,
format: crate::llm_protocol::LlmRequestFormat,
preset: &str,
cancel: bool,
fallback: bool,
) {
use crate::llm_protocol::LlmRequestFormat;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn check_request(socket: &mut tokio::net::TcpStream, gemini: bool) {
async fn check_request(
socket: &mut tokio::net::TcpStream,
gemini: bool,
format: LlmRequestFormat,
prefix: &str,
) {
let mut request = Vec::new();
let mut buf = [0_u8; 4096];
loop {
@@ -2001,6 +2010,25 @@ mod tests {
assert!(headers.contains(":streamGenerateContent?alt=sse"));
} else {
assert_eq!(body["stream"], true);
let suffix = match format {
LlmRequestFormat::ChatCompletions => "chat/completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
assert!(
headers.starts_with(&format!("POST {prefix}/{suffix}?tenant=1 "))
);
let lower = headers.to_ascii_lowercase();
if format == LlmRequestFormat::Messages {
assert!(lower.contains("x-api-key: fixture-key"));
assert!(lower.contains("anthropic-version: 2023-06-01"));
assert!(!lower.contains("authorization:"));
assert!(body["system"]
.as_str()
.is_some_and(|text| !text.is_empty()));
} else {
assert!(lower.contains("authorization: bearer fixture-key"));
}
}
break;
}
@@ -2009,32 +2037,88 @@ mod tests {
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let prefix = if preset == "opencode" {
"/zen/go/v1"
} else {
"/gateway/v1"
};
let endpoint = if gemini {
format!("http://{}", listener.local_addr().unwrap())
} else {
format!(
"http://{}{prefix}/chat/completions?tenant=1",
listener.local_addr().unwrap()
)
};
let (source_sent, source_ready) = tokio::sync::oneshot::channel();
let (advance, next) = tokio::sync::oneshot::channel();
let (finish, finishing) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
check_request(&mut socket, gemini).await;
check_request(&mut socket, gemini, format, prefix).await;
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
let event = |text: &str| {
let value = if gemini {
serde_json::json!({"candidates":[{"content":{"parts":[{"text":text}]}}]})
} else {
serde_json::json!({"choices":[{"delta":{"content":text}}]})
match format {
LlmRequestFormat::ChatCompletions => {
serde_json::json!({"choices":[{"delta":{"content":text}}]})
}
LlmRequestFormat::Responses => {
serde_json::json!({"type":"response.output_text.delta","delta":text})
}
LlmRequestFormat::Messages => {
serde_json::json!({"type":"content_block_delta","delta":{"type":"text_delta","text":text}})
}
}
};
let data = format!("data: {value}\n\n");
format!("{:X}\r\n{data}\r\n", data.len())
};
let end = if gemini {
""
} else {
match format {
LlmRequestFormat::ChatCompletions => "data: [DONE]\n\n",
LlmRequestFormat::Responses => "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
LlmRequestFormat::Messages => "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n",
}
};
let terminal = if end.is_empty() {
String::new()
} else {
format!("{:X}\r\n{end}\r\n", end.len())
};
if !gemini {
let hidden = "[[OPENLESS_TRANSLATION]]do not expose reasoning";
let value = match format {
LlmRequestFormat::ChatCompletions => {
serde_json::json!({"choices":[{"delta":{"reasoning_content":hidden}}]})
}
LlmRequestFormat::Responses => {
serde_json::json!({"type":"response.reasoning_summary_text.delta","delta":hidden})
}
LlmRequestFormat::Messages => {
serde_json::json!({"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":hidden}})
}
};
let frame = format!("data: {value}\n\n");
socket
.write_all(format!("{:X}\r\n{frame}\r\n", frame.len()).as_bytes())
.await
.unwrap();
}
if fallback {
socket
.write_all(event("malformed combined output").as_bytes())
.await
.unwrap();
socket.write_all(terminal.as_bytes()).await.unwrap();
socket.write_all(b"0\r\n\r\n").await.unwrap();
socket.shutdown().await.unwrap();
(socket, _) = listener.accept().await.unwrap();
check_request(&mut socket, gemini).await;
check_request(&mut socket, gemini, format, prefix).await;
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
} else {
socket
@@ -2059,6 +2143,7 @@ mod tests {
return;
}
let _ = socket.write_all(event(" 🌍\n").as_bytes()).await;
let _ = socket.write_all(terminal.as_bytes()).await;
let _ = socket.write_all(b"0\r\n\r\n").await;
});
@@ -2079,16 +2164,24 @@ mod tests {
"fixture-key",
)
.await;
if !gemini {
let request_format = match format {
LlmRequestFormat::ChatCompletions => "chat_completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
write_channel_secret(
credentials.as_ref(),
CredentialNamespace::Llm,
"translation",
crate::llm_protocol::REQUEST_FORMAT_ACCOUNT,
request_format,
)
.await;
}
let polisher = Arc::new(SharedCloudTextPolisher::new(credentials));
let mut context = DictationContext {
llm: ProviderInvocation::new(
"translation",
if gemini {
"gemini"
} else {
"openai-compatible"
},
),
llm: ProviderInvocation::new("translation", if gemini { "gemini" } else { preset }),
..DictationContext::default()
};
context.llm.model = Some("fixture-model".into());
@@ -2177,6 +2270,34 @@ mod tests {
}
}
async fn assert_translation_streams_before_completion(
gemini: bool,
cancel: bool,
fallback: bool,
) {
assert_translation_streams_for_protocol(
gemini,
crate::llm_protocol::LlmRequestFormat::ChatCompletions,
"openai-compatible",
cancel,
fallback,
)
.await;
}
#[tokio::test]
async fn translation_streams_safely_across_channel_protocols_and_zen() {
for format in crate::llm_protocol::LlmRequestFormat::ALL {
for preset in ["custom", "opencode"] {
for fallback in [false, true] {
assert_translation_streams_for_protocol(false, format, preset, false, fallback)
.await;
}
}
assert_translation_streams_for_protocol(false, format, "custom", true, false).await;
}
}
#[tokio::test]
async fn openai_translation_streams_target_before_completion() {
assert_translation_streams_before_completion(false, false, false).await;
@@ -129,6 +129,13 @@ pub struct ChannelTestSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChannelMutation {
InvalidateTest {
kind: ChannelKind,
id: String,
},
InvalidateTests {
kind: ChannelKind,
},
/// Commit a prepared local runtime and its channel in one metadata revision.
ActivateLocalAsr {
id: Option<String>,
@@ -519,6 +526,8 @@ impl CredentialMetadata {
ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr,
ChannelMutation::Create { kind, .. }
| ChannelMutation::SetProviderType { kind, .. }
| ChannelMutation::InvalidateTest { kind, .. }
| ChannelMutation::InvalidateTests { kind }
| ChannelMutation::DeleteIfBlank { kind, .. }
| ChannelMutation::Rename { kind, .. }
| ChannelMutation::Delete { kind, .. }
@@ -630,6 +639,16 @@ impl CredentialMetadata {
channel.last_test = None;
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::InvalidateTest { kind, id } => {
find_channel_mut(&mut self.channels, kind, &id)?.last_test = None;
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::InvalidateTests { kind } => {
for channel in self.channels.entry(kind).or_default() {
channel.last_test = None;
}
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::DeleteIfBlank { kind, id } => {
let channels = self.channels.entry(kind).or_default();
let before = channels.len();
@@ -101,6 +101,10 @@ struct LegacyEntry {
xfyun_api_key: Option<String>,
temperature: Option<f64>,
extra_headers: Option<BTreeMap<String, String>>,
request_format: Option<String>,
messages_thinking: Option<String>,
max_tokens: Option<String>,
thinking_budget: Option<String>,
}
impl Default for LegacyEntry {
@@ -125,6 +129,10 @@ impl Default for LegacyEntry {
xfyun_api_key: None,
temperature: None,
extra_headers: None,
request_format: None,
messages_thinking: None,
max_tokens: None,
thinking_budget: None,
}
}
}
@@ -145,6 +153,10 @@ impl LegacyEntry {
&self.advanced_config,
&self.xfyun_app_id,
&self.xfyun_api_key,
&self.request_format,
&self.messages_thinking,
&self.max_tokens,
&self.thinking_budget,
]
.into_iter()
.any(|value| value.as_deref().is_some_and(|value| !value.is_empty()))
@@ -347,6 +359,15 @@ fn decode_entry(
(endpoint, entry.base_url),
(model, entry.model),
];
if namespace == CredentialNamespace::Llm {
use crate::llm_protocol::*;
fields.extend([
(REQUEST_FORMAT_ACCOUNT, entry.request_format),
(MESSAGES_THINKING_ACCOUNT, entry.messages_thinking),
(MAX_TOKENS_ACCOUNT, entry.max_tokens),
(THINKING_BUDGET_ACCOUNT, entry.thinking_budget),
]);
}
if namespace == CredentialNamespace::Asr {
fields.extend([
(VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key),
@@ -44,6 +44,8 @@ pub enum ProviderKind {
#[serde(rename_all = "camelCase")]
pub struct ProviderRequest {
pub kind: ProviderKind,
#[serde(default)]
pub thinking_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
}
@@ -36,6 +36,7 @@ mod hotkey_interpreter;
pub mod language_catalog;
mod less_computer;
pub mod llm_gemini;
pub mod llm_protocol;
mod marketplace;
pub mod model_store;
pub mod net;
@@ -0,0 +1,772 @@
//! 渠道级文本协议:请求格式、鉴权和正文事件由 Core 统一解释。
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore};
use crate::polish::{LLMError, OpenAICompatibleConfig};
use crate::{BackendError, BackendErrorCode};
pub const REQUEST_FORMAT_ACCOUNT: &str = "ark.request_format";
pub const MESSAGES_THINKING_ACCOUNT: &str = "ark.messages_thinking";
pub const MAX_TOKENS_ACCOUNT: &str = "ark.max_tokens";
pub const THINKING_BUDGET_ACCOUNT: &str = "ark.thinking_budget";
pub const CONFIG_ACCOUNTS: [&str; 4] = [
REQUEST_FORMAT_ACCOUNT,
MESSAGES_THINKING_ACCOUNT,
MAX_TOKENS_ACCOUNT,
THINKING_BUDGET_ACCOUNT,
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmRequestFormat {
#[default]
ChatCompletions,
Responses,
Messages,
}
impl LlmRequestFormat {
pub const ALL: [Self; 3] = [Self::ChatCompletions, Self::Responses, Self::Messages];
pub fn default_for(provider: &str) -> Self {
match provider {
"custom_responses" => Self::Responses,
"custom_messages" => Self::Messages,
_ => Self::ChatCompletions,
}
}
pub fn selectable(provider: &str) -> bool {
!matches!(provider, "gemini" | "codex_oauth")
}
pub fn parse(value: &str) -> Result<Self, BackendError> {
match value.trim() {
"chat_completions" => Ok(Self::ChatCompletions),
"responses" => Ok(Self::Responses),
"messages" => Ok(Self::Messages),
_ => Err(config_error("llmRequestFormatInvalid")),
}
}
pub fn url(self, endpoint: &str) -> Result<String, LLMError> {
endpoint_url(
endpoint,
match self {
Self::ChatCompletions => "/chat/completions",
Self::Responses => "/responses",
Self::Messages => "/messages",
},
)
}
pub fn headers(self, api_key: &str) -> Vec<(String, String)> {
let mut headers = Vec::new();
if self == Self::Messages {
headers.push(("anthropic-version".into(), "2023-06-01".into()));
}
if !api_key.trim().is_empty() {
headers.push(if self == Self::Messages {
("x-api-key".into(), api_key.to_string())
} else {
("Authorization".into(), format!("Bearer {api_key}"))
});
}
headers
}
}
/// 更换格式只替换已知的末端路径,不破坏网关前缀及查询参数。
pub fn endpoint_url(endpoint: &str, suffix: &str) -> Result<String, LLMError> {
let mut url = url::Url::parse(endpoint.trim())
.map_err(|_| LLMError::ParseError("invalid LLM endpoint".into()))?;
let path = url.path().trim_end_matches('/');
let prefix = ["/chat/completions", "/responses", "/messages", "/models"]
.iter()
.find_map(|suffix| path.strip_suffix(suffix))
.unwrap_or(path);
url.set_path(&format!("{prefix}{suffix}"));
Ok(url.to_string())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesThinking {
#[default]
Adaptive,
Budget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmProtocolConfig {
pub format: LlmRequestFormat,
pub messages_thinking: MessagesThinking,
pub max_tokens: u32,
pub thinking_budget: u32,
}
impl Default for LlmProtocolConfig {
fn default() -> Self {
Self {
format: LlmRequestFormat::ChatCompletions,
messages_thinking: MessagesThinking::Adaptive,
max_tokens: 8192,
thinking_budget: 1024,
}
}
}
impl LlmProtocolConfig {
pub fn validate_headers(
&self,
headers: &std::collections::HashMap<String, String>,
) -> Result<(), BackendError> {
if self.format == LlmRequestFormat::Messages
&& headers.keys().any(|name| {
name.eq_ignore_ascii_case("x-api-key")
|| name.eq_ignore_ascii_case("anthropic-version")
})
{
return Err(config_error("llmProtocolHeaderConflict"));
}
Ok(())
}
pub async fn load(
store: &dyn CredentialStore,
channel: &str,
provider: &str,
) -> Result<Self, BackendError> {
let mut config = Self {
format: LlmRequestFormat::default_for(provider),
..Self::default()
};
if !LlmRequestFormat::selectable(provider) {
return Ok(config);
}
for account in CONFIG_ACCOUNTS {
let key =
CredentialKey::new(CredentialNamespace::Llm, Some(channel.to_string()), account)?;
if let Some(value) = store.read(key).await? {
config.apply(account, value.expose_secret())?;
}
}
config.validate()?;
Ok(config)
}
pub fn apply(&mut self, account: &str, value: &str) -> Result<(), BackendError> {
let value = value.trim();
if value.is_empty() {
return Ok(());
}
match account {
REQUEST_FORMAT_ACCOUNT => self.format = LlmRequestFormat::parse(value)?,
MESSAGES_THINKING_ACCOUNT => {
self.messages_thinking = match value {
"adaptive" => MessagesThinking::Adaptive,
"budget" => MessagesThinking::Budget,
_ => return Err(config_error("llmThinkingModeInvalid")),
}
}
MAX_TOKENS_ACCOUNT => self.max_tokens = positive_tokens(value)?,
THINKING_BUDGET_ACCOUNT => {
self.thinking_budget = positive_tokens(value)?;
if self.thinking_budget < 1024 {
return Err(config_error("llmThinkingBudgetInvalid"));
}
}
_ => return Err(config_error("llmRequestFormatInvalid")),
}
Ok(())
}
pub fn validate(&self) -> Result<(), BackendError> {
if self.format == LlmRequestFormat::Messages
&& (self.max_tokens == 0
|| self.messages_thinking == MessagesThinking::Budget
&& (self.thinking_budget < 1024 || self.thinking_budget >= self.max_tokens))
{
return Err(config_error("llmThinkingBudgetInvalid"));
}
Ok(())
}
}
fn positive_tokens(value: &str) -> Result<u32, BackendError> {
value
.parse::<u32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| config_error("llmTokenLimitInvalid"))
}
fn config_error(message: &str) -> BackendError {
BackendError::new(BackendErrorCode::InvalidArgument, message)
}
pub(crate) fn request_body(
config: &OpenAICompatibleConfig,
stream: bool,
messages: Vec<Value>,
) -> Value {
let mut body = match config.protocol.format {
LlmRequestFormat::ChatCompletions => {
unreachable!("Chat Completions retains its provider rules")
}
LlmRequestFormat::Responses => {
let mut body =
json!({"model": config.model, "stream": stream, "store": false, "input": messages});
let model = config
.model
.trim()
.strip_prefix("openai/")
.unwrap_or(config.model.trim())
.to_ascii_lowercase();
// 已知普通模型不接受 reasoning;未知网关模型按所选兼容协议声明参数。
if !(model.starts_with("gpt-4")
|| model.starts_with("gpt-3.5")
|| model.starts_with("chatgpt-4"))
{
let effort = if model.starts_with("gpt-5-pro")
|| model.contains("-pro") && model.starts_with("gpt-5.")
{
"high"
} else if config.thinking_enabled {
"medium"
} else {
"low"
};
body["reasoning"] = json!({"effort": effort});
}
body
}
LlmRequestFormat::Messages => {
let mut system = Vec::new();
let mut turns = Vec::new();
for message in messages {
if matches!(message["role"].as_str(), Some("system" | "developer")) {
if let Some(text) = message["content"].as_str() {
system.push(text.to_string());
}
} else {
turns.push(message);
}
}
let mut body = json!({"model": config.model, "stream": stream, "messages": turns, "max_tokens": config.protocol.max_tokens});
if !system.is_empty() {
body["system"] = json!(system.join("\n\n"));
}
if config.thinking_enabled {
body["thinking"] =
if config.protocol.messages_thinking == MessagesThinking::Adaptive {
json!({"type": "adaptive"})
} else {
json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget})
};
}
body
}
};
if body.get("reasoning").is_none()
&& !(config.protocol.format == LlmRequestFormat::Messages && config.thinking_enabled)
{
if let Some(temperature) = config.temperature {
body["temperature"] = json!(temperature);
}
}
body
}
fn response_error(message: &str) -> LLMError {
LLMError::ParseError(message.to_string())
}
fn check_stop_reason(value: &Value) -> Result<bool, LLMError> {
if let Some(reason) = value.as_str() {
if !matches!(reason, "end_turn" | "stop_sequence") {
return Err(response_error("llmResponseIncomplete"));
}
return Ok(true);
}
Ok(false)
}
pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result<String, LLMError> {
if format == LlmRequestFormat::ChatCompletions {
return crate::polish::extract_assistant_content(text);
}
let value: Value =
serde_json::from_str(text).map_err(|_| response_error("invalid LLM JSON"))?;
if !value["error"].is_null() {
return Err(response_error("llmStreamError"));
}
let mut output = String::new();
match format {
LlmRequestFormat::Responses => {
if value["status"] != "completed" {
return Err(response_error("llmResponseIncomplete"));
}
if let Some(items) = value["output"].as_array() {
for item in items {
if item["type"] == "message" && item["role"] == "assistant" {
append_blocks(&mut output, &item["content"], "output_text");
}
}
}
}
LlmRequestFormat::Messages => {
if !check_stop_reason(&value["stop_reason"])? {
return Err(response_error("llmResponseIncomplete"));
}
append_blocks(&mut output, &value["content"], "text");
}
LlmRequestFormat::ChatCompletions => unreachable!(),
}
if output.is_empty() {
return Err(response_error("empty LLM response"));
}
Ok(crate::polish::clean_polish_output(&output))
}
fn append_blocks(output: &mut String, blocks: &Value, kind: &str) {
if let Some(blocks) = blocks.as_array() {
for block in blocks {
if block["type"] == kind {
if let Some(text) = block["text"].as_str() {
output.push_str(text);
}
}
}
}
}
pub(crate) enum StreamEvent {
Text(String),
Done,
Ignore,
}
/// 共用 SSE 分帧;保留未完整的 UTF-8 字节,不能逐个 HTTP chunk 有损解码。
pub(crate) struct TextEventStream {
format: LlmRequestFormat,
buffer: String,
pending: Vec<u8>,
messages_complete: bool,
pub done: bool,
}
impl TextEventStream {
pub fn new(format: LlmRequestFormat) -> Self {
Self {
format,
buffer: String::new(),
pending: Vec::new(),
messages_complete: false,
done: false,
}
}
pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> {
crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?;
// 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。
if self.buffer.contains("\r\n") {
self.buffer = self.buffer.replace("\r\n", "\n");
}
Ok(())
}
pub fn next(&mut self) -> Result<Option<StreamEvent>, LLMError> {
if self.done {
return Ok(None);
}
let Some(end) = self.buffer.find("\n\n") else {
return Ok(None);
};
let event = self.buffer[..end].to_string();
self.buffer.drain(..end + 2);
let data = event
.lines()
.filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() {
return Ok(Some(StreamEvent::Ignore));
}
if self.format == LlmRequestFormat::ChatCompletions && data.trim() == "[DONE]" {
self.done = true;
return Ok(Some(StreamEvent::Done));
}
let value: Value = match serde_json::from_str(&data) {
Ok(value) => value,
Err(_) if self.format == LlmRequestFormat::ChatCompletions => {
return Ok(Some(StreamEvent::Ignore))
}
Err(_) => return Err(response_error("invalid LLM SSE JSON")),
};
let kind = value["type"]
.as_str()
.or_else(|| {
event
.lines()
.find_map(|line| line.strip_prefix("event:").map(str::trim))
})
.unwrap_or("");
if kind == "error" || !value["error"].is_null() {
return Err(response_error("llmStreamError"));
}
let text = match self.format {
LlmRequestFormat::ChatCompletions => value["choices"][0]["delta"]["content"].as_str(),
LlmRequestFormat::Responses => match kind {
"response.output_text.delta" => value["delta"].as_str(),
"response.completed" => {
if value["response"]["status"]
.as_str()
.is_some_and(|s| s != "completed")
{
return Err(response_error("llmResponseIncomplete"));
}
self.done = true;
None
}
"response.failed" | "response.incomplete" => {
return Err(response_error("llmResponseIncomplete"))
}
_ => None,
},
LlmRequestFormat::Messages => match kind {
"content_block_start" if value["content_block"]["type"] == "text" => {
value["content_block"]["text"].as_str()
}
"content_block_delta" if value["delta"]["type"] == "text_delta" => {
value["delta"]["text"].as_str()
}
"message_delta" => {
if check_stop_reason(&value["delta"]["stop_reason"])? {
self.messages_complete = true;
}
None
}
"message_stop" => {
if !self.messages_complete {
return Err(response_error("llmResponseIncomplete"));
}
self.done = true;
None
}
_ => None,
},
};
Ok(Some(
if let Some(text) = text.filter(|text| !text.is_empty()) {
StreamEvent::Text(text.to_string())
} else if self.done {
StreamEvent::Done
} else {
StreamEvent::Ignore
},
))
}
pub fn finish(&mut self) -> Result<(), LLMError> {
crate::polish::finish_utf8_sse_chunks(&mut self.buffer, &mut self.pending)?;
if self.format != LlmRequestFormat::ChatCompletions && !self.done {
return Err(response_error("llmResponseIncomplete"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::{InMemoryCredentialStore, SecretValue};
#[tokio::test]
async fn protocol_defaults_overrides_and_channels_are_independent() {
let store = InMemoryCredentialStore::default();
for (provider, expected) in [
("custom", LlmRequestFormat::ChatCompletions),
("custom_responses", LlmRequestFormat::Responses),
("custom_messages", LlmRequestFormat::Messages),
("deepseek", LlmRequestFormat::ChatCompletions),
] {
assert_eq!(
LlmProtocolConfig::load(&store, "a", provider)
.await
.unwrap()
.format,
expected
);
}
let key = CredentialKey::new(
CredentialNamespace::Llm,
Some("a".into()),
REQUEST_FORMAT_ACCOUNT,
)
.unwrap();
store
.write(key.clone(), SecretValue::new("messages"))
.await
.unwrap();
assert_eq!(
LlmProtocolConfig::load(&store, "a", "openai")
.await
.unwrap()
.format,
LlmRequestFormat::Messages
);
assert_eq!(
LlmProtocolConfig::load(&store, "b", "openai")
.await
.unwrap()
.format,
LlmRequestFormat::ChatCompletions
);
store.write(key, SecretValue::new("invalid")).await.unwrap();
assert!(LlmProtocolConfig::load(&store, "a", "openai")
.await
.is_err());
}
#[test]
fn urls_and_auth_follow_format_without_losing_gateway_paths() {
for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) {
if LlmRequestFormat::selectable(descriptor.provider_type.as_str()) {
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(
descriptor.default_request_format,
Some(LlmRequestFormat::default_for(
descriptor.provider_type.as_str()
))
);
} else {
assert!(descriptor.supported_request_formats.is_empty());
assert!(descriptor.default_request_format.is_none());
}
}
for format in LlmRequestFormat::ALL {
let suffix = match format {
LlmRequestFormat::ChatCompletions => "chat/completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
for old in [
"",
"/chat/completions/",
"/responses",
"/messages/",
"/models",
] {
let base = format!("https://example.com/gateway/v1{old}?tenant=1#local");
assert_eq!(
format.url(&base).unwrap(),
format!("https://example.com/gateway/v1/{suffix}?tenant=1#local")
);
assert_eq!(
endpoint_url(&base, "/models").unwrap(),
"https://example.com/gateway/v1/models?tenant=1#local"
);
}
let headers = format.headers("test-key");
if format == LlmRequestFormat::Messages {
assert!(headers.contains(&("x-api-key".into(), "test-key".into())));
assert!(!headers.iter().any(|(name, _)| name == "Authorization"));
} else {
assert_eq!(
headers,
vec![("Authorization".into(), "Bearer test-key".into())]
);
}
}
let headers =
crate::provider_rules::parse_extra_headers(r#"{"X-API-Key":"override"}"#).unwrap();
assert!(LlmProtocolConfig::default()
.validate_headers(&headers)
.is_ok());
assert!(LlmProtocolConfig {
format: LlmRequestFormat::Messages,
..Default::default()
}
.validate_headers(&headers)
.is_err());
}
#[test]
fn request_shapes_and_thinking_do_not_leak_between_protocols() {
let messages = vec![
json!({"role":"system","content":"rules"}),
json!({"role":"user","content":"old"}),
json!({"role":"assistant","content":"answer"}),
json!({"role":"user","content":"new"}),
];
let mut config = OpenAICompatibleConfig::new(
"deepseek",
"test",
"https://example.com/v1",
"key",
"gateway-model",
)
.with_temperature(Some(0.5));
config.protocol.format = LlmRequestFormat::Responses;
for enabled in [false, true] {
config.thinking_enabled = enabled;
let body = request_body(&config, true, messages.clone());
assert_eq!(body["input"], json!(messages));
assert_eq!(body["store"], false);
assert_eq!(
body["reasoning"]["effort"],
if enabled { "medium" } else { "low" }
);
for absent in [
"messages",
"thinking",
"enable_thinking",
"reasoning_effort",
"temperature",
] {
assert!(body.get(absent).is_none());
}
}
config.model = "gpt-4o".into();
let body = request_body(&config, false, messages.clone());
assert!(body.get("reasoning").is_none());
assert_eq!(body["temperature"], 0.5);
config.model = "gpt-5-pro".into();
assert_eq!(
request_body(&config, false, messages.clone())["reasoning"]["effort"],
"high"
);
config.protocol.format = LlmRequestFormat::Messages;
for enabled in [false, true] {
config.thinking_enabled = enabled;
for mode in [MessagesThinking::Adaptive, MessagesThinking::Budget] {
config.protocol.messages_thinking = mode;
let body = request_body(&config, false, messages.clone());
assert_eq!(body["system"], "rules");
assert_eq!(body["messages"], json!(&messages[1..]));
assert_eq!(body["max_tokens"], 8192);
if enabled {
assert_eq!(
body["thinking"]["type"],
if mode == MessagesThinking::Adaptive {
"adaptive"
} else {
"enabled"
}
);
} else {
assert!(body.get("thinking").is_none());
}
assert_eq!(body.get("temperature").is_none(), enabled);
if enabled && mode == MessagesThinking::Budget {
assert_eq!(body["thinking"]["budget_tokens"], 1024);
}
for absent in ["input", "reasoning", "reasoning_effort", "enable_thinking"] {
assert!(body.get(absent).is_none());
}
}
}
}
#[test]
fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() {
let mut config = LlmProtocolConfig {
format: LlmRequestFormat::Messages,
messages_thinking: MessagesThinking::Budget,
..Default::default()
};
for value in ["0", "-1", "1.5", "4294967296"] {
assert!(config.apply(MAX_TOKENS_ACCOUNT, value).is_err());
}
assert!(config.apply(THINKING_BUDGET_ACCOUNT, "1023").is_err());
config.apply(THINKING_BUDGET_ACCOUNT, "8192").unwrap();
assert!(config.validate().is_err());
config.apply(MAX_TOKENS_ACCOUNT, "10000").unwrap();
config.validate().unwrap();
for (format, body) in [
(
LlmRequestFormat::Responses,
json!({"status":"completed", "output":[{"type":"reasoning","summary":"secret"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]}]}),
),
(
LlmRequestFormat::Messages,
json!({"stop_reason":"end_turn", "content":[{"type":"thinking","thinking":"secret"},{"type":"text","text":"你"},{"type":"text","text":"好"}]}),
),
] {
assert_eq!(extract_text(format, &body.to_string()).unwrap(), "你好");
}
assert!(extract_text(
LlmRequestFormat::Responses,
r#"{"status":"incomplete","output":[]}"#
)
.is_err());
assert!(extract_text(
LlmRequestFormat::Messages,
r#"{"stop_reason":"max_tokens","content":[{"type":"text","text":"partial"}]}"#
)
.is_err());
}
#[test]
fn sse_handles_every_byte_boundary_and_requires_successful_termination() {
for (format, fixture) in [
(LlmRequestFormat::Responses, "event: response.output_text.delta\r\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"你好🙂\"}\r\n\r\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"secret\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"),
(LlmRequestFormat::Messages, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好🙂\"}}\r\n\r\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"secret\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n"),
] {
for size in 1..=fixture.len() {
let mut stream = TextEventStream::new(format);
let mut text = String::new();
for chunk in fixture.as_bytes().chunks(size) {
stream.push(chunk).unwrap();
while let Some(event) = stream.next().unwrap() {
if let StreamEvent::Text(delta) = event { text.push_str(&delta); }
}
}
stream.finish().unwrap();
assert_eq!(text, "你好🙂");
}
assert!(TextEventStream::new(format).finish().is_err());
}
for (format, event) in [
(LlmRequestFormat::Responses, r#"{"type":"response.failed"}"#),
(
LlmRequestFormat::Responses,
r#"{"type":"response.incomplete"}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"error","error":{"message":"secret"}}"#,
),
] {
let mut stream = TextEventStream::new(format);
stream
.push(format!("data: {event}\n\n").as_bytes())
.unwrap();
let error = stream
.next()
.err()
.expect("must reject unsuccessful streams");
assert!(!error.to_string().contains("secret"));
}
let mut stream = TextEventStream::new(LlmRequestFormat::Messages);
stream
.push(
b"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n",
)
.unwrap();
assert!(matches!(stream.next().unwrap(), Some(StreamEvent::Text(_))));
let error = match stream.next() {
Err(error) => error,
Ok(_) => panic!("message_stop without stop_reason must fail"),
};
assert!(error.to_string().contains("llmResponseIncomplete"));
}
}
@@ -1,6 +1,6 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
#![allow(clippy::too_many_arguments)]
//! OpenAI-compatible chat completions client + polish prompts.
//! 渠道级文本协议客户端与润色提示词。
//!
//! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例`
//! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。
@@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use thiserror::Error;
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, StreamEvent, TextEventStream};
use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage};
use crate::types::PolishMode;
@@ -94,6 +95,7 @@ pub(crate) fn polish_total_timeout_secs(input_chars: usize) -> Duration {
#[derive(Clone, Debug)]
pub struct OpenAICompatibleConfig {
pub protocol: LlmProtocolConfig,
pub provider_id: String,
pub display_name: String,
pub base_url: String,
@@ -120,6 +122,10 @@ impl OpenAICompatibleConfig {
let temperature = openai_compatible_temperature_for_provider(&provider_id, None);
Self {
protocol: LlmProtocolConfig {
format: LlmRequestFormat::default_for(&provider_id),
..Default::default()
},
provider_id,
display_name: display_name.into(),
base_url: base_url.into(),
@@ -137,6 +143,11 @@ impl OpenAICompatibleConfig {
self
}
pub fn with_protocol(mut self, protocol: LlmProtocolConfig) -> Self {
self.protocol = protocol;
self
}
pub fn with_extra_headers(mut self, extra_headers: HashMap<String, String>) -> Self {
self.extra_headers = extra_headers;
self
@@ -176,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
| "codingPlanX"
| "minimax"
| "stepfun"
| "opencode"
)
}
@@ -234,6 +246,12 @@ impl ActiveLLMProvider {
}
}
/// Channel formats use their protocol decoder; Codex uses its dedicated
/// Responses transport. Gemini is routed separately by cloud_providers.
pub fn supports_streaming_polish(&self) -> bool {
matches!(self, Self::OpenAI(_) | Self::Codex(_))
}
pub async fn polish_streaming<F, C>(
&self,
raw_text: &str,
@@ -473,7 +491,6 @@ impl ActiveLLMProvider {
pub struct OpenAICompatibleLLMProvider {
config: OpenAICompatibleConfig,
client: reqwest::Client,
/// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的
/// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。
///
@@ -489,15 +506,8 @@ impl OpenAICompatibleLLMProvider {
// pool survives across utterances instead of paying a fresh TLS handshake
// every polish. Falls back to a default client if the builder somehow fails
// so we still surface a useful error at request time.
let timeout = config.request_timeout_secs;
let no_proxy =
crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_proxy());
let base_url = config.base_url.clone();
let client = crate::net::cached_client((timeout, no_proxy), || {
http_client_builder(&base_url, timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
});
let polish_base_url = config.base_url.clone();
let polish_client =
crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || {
@@ -507,7 +517,6 @@ impl OpenAICompatibleLLMProvider {
});
Self {
config,
client,
polish_client,
}
}
@@ -718,7 +727,7 @@ impl OpenAICompatibleLLMProvider {
user_prompt: &str,
budget: Duration,
) -> Result<String, LLMError> {
let url = chat_completions_url(&self.config.base_url);
let url = self.config.protocol.format.url(&self.config.base_url)?;
let messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt);
let body = self.chat_body(false, messages);
@@ -740,7 +749,7 @@ impl OpenAICompatibleLLMProvider {
user_prompt: &str,
budget: Duration,
) -> Result<String, LLMError> {
let url = chat_completions_url(&self.config.base_url);
let url = self.config.protocol.format.url(&self.config.base_url)?;
let body = self.chat_body(
false,
vec![
@@ -760,6 +769,9 @@ impl OpenAICompatibleLLMProvider {
}
fn chat_body(&self, stream: bool, messages: Vec<Value>) -> Value {
if self.config.protocol.format != LlmRequestFormat::ChatCompletions {
return crate::llm_protocol::request_body(&self.config, stream, messages);
}
let mut body = json!({
"model": self.config.model,
"stream": stream,
@@ -810,17 +822,19 @@ impl OpenAICompatibleLLMProvider {
url: &str,
body: &serde_json::Value,
) -> Result<String, LLMError> {
let mut request = self
.polish_client
.post(url)
.header("Content-Type", "application/json");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(body);
self.config
.protocol
.validate()
.and_then(|_| {
self.config
.protocol
.validate_headers(&self.config.extra_headers)
})
.map_err(|error| LLMError::ParseError(error.message))?;
let request = self
.authorize(self.polish_client.post(url))
.header("Content-Type", "application/json")
.json(body);
let response = send_with_transient_retry(request).await?;
@@ -838,12 +852,10 @@ impl OpenAICompatibleLLMProvider {
});
}
extract_assistant_content(&body_text)
crate::llm_protocol::extract_text(self.config.protocol.format, &body_text)
}
/// 与 `chat_completion` 同条 HTTP 通路,但开 `stream: true` 并把 SSE chunk 一边
/// 解析、一边通过 `on_delta` 推给调用方(用于实时把答案塞进浮窗气泡)。
/// 最终返回拼好的完整字符串供调用方写入对话历史。
/// 问答与润色共用协议解码,但问答保留配置中的整请求预算。
async fn chat_completion_history_streaming<F, C>(
&self,
system_prompt: &str,
@@ -855,125 +867,27 @@ impl OpenAICompatibleLLMProvider {
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let mut msgs: Vec<Value> = Vec::with_capacity(history.len() + 1);
msgs.push(json!({ "role": "system", "content": system_prompt }));
for m in history {
msgs.push(json!({ "role": m.role, "content": m.content }));
let mut messages = vec![json!({ "role": "system", "content": system_prompt })];
for message in history {
messages.push(json!({ "role": message.role, "content": message.content }));
}
let url = chat_completions_url(&self.config.base_url);
let body = self.chat_body(true, msgs);
log::info!(
"[llm] POST {} provider={} model={} chat_turns={} stream=true",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
history.len()
);
let mut request = self
.client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(&body);
let response = send_with_transient_retry(request).await?;
let status = response.status();
if !status.is_success() {
// 失败时仍把 body 读一遍方便诊断
let body_text = response.text().await.map_err(llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::error!("[llm] HTTP {} body={}", status.as_u16(), preview);
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
});
}
// SSE 流:一帧 = 若干行,以 `\n\n` 分隔。每行如 `data: {...}` 或 `data: [DONE]`。
// 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。
let mut response = response;
let mut buffer = String::new();
let mut utf8_pending: Vec<u8> = Vec::new();
let mut full_text = String::new();
let mut cancelled = false;
loop {
// 取消旗标:用户取消 / 关浮窗时立即 break,不再 drain HTTP body。
// 否则 reqwest 会读完整个流(包括 LLM 后续 token)烧 quota。详见 issue #161。
if should_cancel() {
log::info!("[llm] stream cancelled by caller; breaking SSE loop");
cancelled = true;
break;
}
let chunk_opt = response.chunk().await.map_err(llm_error_from_reqwest)?;
let Some(chunk) = chunk_opt else { break };
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
while let Some(idx) = buffer.find("\n\n") {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let v: Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[llm] SSE parse skip: {e}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
if !delta.is_empty() {
full_text.push_str(delta);
on_delta(delta);
}
}
}
}
}
if !cancelled {
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
}
log::info!(
"[llm] HTTP 200 stream done; total chars={}",
full_text.chars().count()
);
if full_text.is_empty() {
return Err(LLMError::InvalidResponse {
status: 200,
body: "empty stream".to_string(),
});
}
Ok(full_text)
let budget = Duration::from_secs(self.config.request_timeout_secs);
tokio::time::timeout(
budget,
self.chat_completion_messages_streaming(
messages,
StreamingTimeouts {
first_token: budget,
idle: budget,
},
on_delta,
should_cancel,
),
)
.await
.map_err(|_| LLMError::Timeout)?
}
/// 把已经构造好的 `messages` 列表(包含 system + 历史 + 当前 user)作为
/// `stream: true` 的 body 发出去,SSE 一帧一帧解析。供 `polish_streaming` 复用,
/// 跟 `chat_completion_history_streaming` 的 SSE 解析逻辑同款 —— 后者多了一步从
/// `QaChatMessage[]` 装配 messages 的工作。
async fn chat_completion_messages_streaming<F, C>(
&self,
messages: Vec<Value>,
@@ -985,151 +899,132 @@ impl OpenAICompatibleLLMProvider {
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let url = chat_completions_url(&self.config.base_url);
if should_cancel() {
return Err(LLMError::Network("cancelled".into()));
}
self.config
.protocol
.validate()
.and_then(|_| {
self.config
.protocol
.validate_headers(&self.config.extra_headers)
})
.map_err(|error| LLMError::ParseError(error.message))?;
let url = self.config.protocol.format.url(&self.config.base_url)?;
let body = self.chat_body(true, messages);
let mut request = self
.polish_client
.post(&url)
log::info!(
"[llm] POST {} provider={} model={} format={:?} stream=true",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
self.config.protocol.format
);
let request = self
.authorize(self.polish_client.post(&url))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(&body);
let response = send_with_transient_retry(request).await?;
.header("Accept", "text/event-stream")
.json(&body);
let started = std::time::Instant::now();
// 取消要能唤醒正在等待网络数据的请求,不能只在 chunk 之间检查。
let cancellation = async {
while !should_cancel() {
tokio::time::sleep(Duration::from_millis(25)).await;
}
};
tokio::pin!(cancellation);
let mut response = tokio::select! {
_ = &mut cancellation => return Err(LLMError::Network("cancelled".into())),
result = tokio::time::timeout(timeouts.first_token, send_with_transient_retry(request)) => {
result.map_err(|_| LLMError::Timeout)??
}
};
let status = response.status();
if !status.is_success() {
let body_text = response.text().await.map_err(llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::error!("[llm] streaming HTTP {} body={}", status.as_u16(), preview);
let body_text = tokio::select! {
_ = &mut cancellation => return Err(LLMError::Network("cancelled".into())),
result = tokio::time::timeout(timeouts.first_token.saturating_sub(started.elapsed()), response.text()) => {
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
}
};
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
body: safe_str_slice(&body_text, BODY_PREVIEW_LIMIT.min(body_text.len()))
.to_string(),
});
}
let mut response = response;
let mut buffer = String::new();
let mut utf8_pending: Vec<u8> = Vec::new();
let mut events = TextEventStream::new(self.config.protocol.format);
let mut full_text = String::new();
let mut delta_count: u64 = 0;
let mut cancelled = false;
let stream_started = std::time::Instant::now();
let mut first_content_at: Option<Duration> = None;
loop {
while !events.done {
if should_cancel() {
log::info!(
"[llm] polish stream cancelled by caller after {} deltas ({} chars); breaking SSE loop",
delta_count,
full_text.chars().count()
);
cancelled = true;
break;
}
// 首字之前用「还剩多少首字预算」,首字之后用「两个 chunk 之间能空多久」。
// 注意首字预算是从请求发出起算的**总量**,不随 chunk 到达而重置——推理模型
// 思考期的 reasoning_content 是一串正常 chunk,若让它续命,用户干等就没有上限。
let budget = match first_content_at {
None => timeouts
.first_token
.saturating_sub(stream_started.elapsed()),
Some(_) => timeouts.idle,
let budget = if full_text.is_empty() {
timeouts.first_token.saturating_sub(started.elapsed())
} else {
timeouts.idle
};
let chunk_opt = match tokio::time::timeout(budget, response.chunk()).await {
Ok(result) => result.map_err(llm_error_from_reqwest)?,
Err(_) => {
// 已经交给 on_delta 的字此刻就在用户屏幕上;上层 dictation 的 Failed
// 分支拿 typed_text 当 final_text,屏幕 / history / 剪贴板保持一致。
match first_content_at {
None => log::error!(
"[llm] polish stream timed out waiting for first content delta (budget {:?}); \
模型可能仍在思考——加长首字预算或换非推理模型",
timeouts.first_token
),
Some(first) => log::error!(
"[llm] polish stream stalled {:?} after {} chars (first delta at {:?}); \
已落屏的字保留",
timeouts.idle,
full_text.chars().count(),
first
),
}
return Err(LLMError::Timeout);
let chunk = tokio::select! {
_ = &mut cancellation => { cancelled = true; break; }
result = tokio::time::timeout(budget, response.chunk()) => {
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
}
};
let Some(chunk) = chunk_opt else { break };
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
while let Some(idx) = buffer.find("\n\n") {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let v: Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[llm] polish SSE parse skip: {e}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
if !delta.is_empty() {
if first_content_at.is_none() {
let elapsed = stream_started.elapsed();
first_content_at = Some(elapsed);
// 首字延迟是判断「模型思考太久」还是「网络卡住」的关键读数。
// 之前日志里没有它,7 分钟录音那次只能靠外部实测才量出 43s。
log::info!(
"[llm] polish stream first content delta after {:.2}s (budget {:?})",
elapsed.as_secs_f64(),
timeouts.first_token
);
}
full_text.push_str(delta);
delta_count += 1;
on_delta(delta);
}
}
let Some(chunk) = chunk else {
break;
};
events.push(&chunk)?;
loop {
if should_cancel() {
cancelled = true;
break;
}
let Some(event) = events.next()? else {
break;
};
if let StreamEvent::Text(delta) = event {
if full_text.is_empty() {
log::info!(
"[llm] first content delta after {:.2}s",
started.elapsed().as_secs_f64()
);
}
full_text.push_str(&delta);
on_delta(&delta);
}
}
if cancelled {
break;
}
}
if !cancelled {
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
events.finish()?;
}
log::info!(
"[llm] polish stream done; total deltas={} chars={}",
delta_count,
"[llm] stream done; cancelled={} chars={}",
cancelled,
full_text.chars().count()
);
if full_text.is_empty() {
return Err(LLMError::InvalidResponse {
status: 200,
body: "empty polish stream".to_string(),
body: "empty polish stream".into(),
});
}
Ok(full_text)
}
fn authorize(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
for (name, value) in self.config.protocol.format.headers(&self.config.api_key) {
request = request.header(name, value);
}
for (name, value) in &self.config.extra_headers {
request = request.header(name, value);
}
request
}
}
#[derive(Clone, Debug)]
@@ -1905,8 +1800,23 @@ pub(crate) fn apply_openai_compatible_thinking_control(
) {
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
let control = openai_compatible_thinking_control(provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url));
// Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。
let is_opencode = provider_id.trim() == "opencode"
|| (matches!(
provider_id.trim(),
"custom" | "custom_responses" | "custom_messages"
) && url::Url::parse(base_url.trim())
.ok()
.is_some_and(|url| url.host_str() == Some("opencode.ai")));
let control = if is_opencode {
model
.trim()
.starts_with("deepseek-")
.then_some(ThinkingControl::DeepSeekThinking)
} else {
openai_compatible_thinking_control(provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url))
};
match control {
Some(ThinkingControl::ReasoningEffort) => {
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
@@ -2084,7 +1994,7 @@ mod tests {
"https://user:pass@example.com/v1/chat/completions?token=query-secret#client-fragment"
);
}
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex as StdMutex;
use std::thread;
@@ -2379,6 +2289,256 @@ mod tests {
request
}
#[tokio::test]
async fn all_text_entrypoints_use_the_selected_protocol_over_http() {
for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| {
[
("custom", "/gateway/v1"),
("opencode", "/zen/v1"),
("opencode", "/zen/go/v1"),
]
.map(|entry| (format, entry))
}) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for index in 0..6 {
let (mut stream, _) = listener.accept().unwrap();
let request = read_http_request(&mut stream);
let split = request.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
let headers = String::from_utf8_lossy(&request[..split]).to_ascii_lowercase();
let body: Value = serde_json::from_slice(&request[split + 4..]).unwrap();
let path = match format {
LlmRequestFormat::ChatCompletions => "chat/completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
assert!(headers.starts_with(&format!("post {prefix}/{path}?tenant=1 ")));
if format == LlmRequestFormat::Messages {
assert!(headers.contains("x-api-key: fixture-key"));
assert!(headers.contains("anthropic-version: 2023-06-01"));
assert!(!headers.contains("authorization:"));
assert!(body["system"].as_str().is_some_and(|text| !text.is_empty()));
} else {
assert!(headers.contains("authorization: bearer fixture-key"));
}
assert!(!headers.contains("chatgpt-account-id"));
let messages = if format == LlmRequestFormat::Responses {
&body["input"]
} else {
&body["messages"]
};
assert!(messages
.as_array()
.is_some_and(|messages| !messages.is_empty()));
if index == 1 {
assert!(messages
.as_array()
.unwrap()
.iter()
.any(|m| m["role"] == "assistant" && m["content"] == "prior answer"));
}
if index < 3 {
assert_eq!(body["stream"], false);
let response = match format {
LlmRequestFormat::ChatCompletions => json!({"choices":[{"message":{"content":"你好"}}]}),
LlmRequestFormat::Responses => json!({"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你好"}]}]}),
LlmRequestFormat::Messages => json!({"stop_reason":"end_turn","content":[{"type":"text","text":"你好"}]}),
}.to_string();
write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
} else {
assert_eq!(body["stream"], true);
let response = match format {
LlmRequestFormat::ChatCompletions => "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n",
LlmRequestFormat::Responses => "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好\"}\r\n\r\ndata: {\"type\":\"response.completed\"}\r\n\r\n",
LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n",
};
let split = response.find('好').unwrap() + 1;
write_chunked_sse_response(
&mut stream,
&[&response.as_bytes()[..split], &response.as_bytes()[split..]],
);
}
}
});
let config = OpenAICompatibleConfig::new(
preset,
"test",
format!("http://{address}{prefix}/chat/completions?tenant=1"),
"fixture-key",
"test",
)
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
});
let provider = OpenAICompatibleLLMProvider::new(config);
for history in [vec![], vec![("prior input".into(), "prior answer".into())]] {
assert_eq!(
provider
.polish(
"input",
PolishMode::Light,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
None,
&history
)
.await
.unwrap(),
"你好"
);
}
assert_eq!(
provider
.translate_to(
"hello",
"Chinese",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None
)
.await
.unwrap(),
"你好"
);
let output = std::sync::Mutex::new(String::new());
let delta = |text: &str| output.lock().unwrap().push_str(text);
let history = vec![QaChatMessage {
role: "user".into(),
content: "hello".into(),
selection_text: None,
}];
assert_eq!(
provider
.answer_chat_streaming(
&history,
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
delta,
|| false
)
.await
.unwrap(),
"你好"
);
assert_eq!(*output.lock().unwrap(), "你好");
output.lock().unwrap().clear();
assert_eq!(
provider
.polish_streaming(
"input",
PolishMode::Light,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
None,
&[],
delta,
|| false
)
.await
.unwrap(),
"你好"
);
assert_eq!(*output.lock().unwrap(), "你好");
output.lock().unwrap().clear();
assert_eq!(
provider
.translate_to_streaming(
"hello",
"Chinese",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
delta,
|| false,
)
.await
.unwrap(),
"你好"
);
assert_eq!(*output.lock().unwrap(), "你好");
server.join().unwrap();
}
}
#[test]
fn opencode_thinking_is_scoped_to_model_host_and_protocol() {
for (preset, endpoint, zen) in [
("opencode", "https://opencode.ai/zen/v1", true),
("opencode", "https://gateway.example/v1", true),
("custom", "https://opencode.ai/zen/v1", true),
("custom_responses", "https://opencode.ai/zen/v1", true),
("custom_messages", "https://opencode.ai/zen/v1", true),
(
"custom",
"https://OPENCODE.AI:443/zen/go/v1/chat/completions",
true,
),
("custom", "https://opencode.ai.example/zen/v1", false),
("custom", "https://fakeopencode.ai/zen/v1", false),
("custom", "https://opencode.ai@example.com/zen/v1", false),
("custom", "https://example.com/opencode.ai", false),
] {
for model in ["deepseek-v4-flash", "minimax-m3", "gateway-model"] {
for enabled in [false, true] {
for format in LlmRequestFormat::ALL {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model)
.with_thinking_enabled(enabled)
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
}),
);
let body =
provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]);
match format {
LlmRequestFormat::ChatCompletions
if zen && model.starts_with("deepseek-") =>
{
assert_eq!(
body["thinking"]["type"],
if enabled { "enabled" } else { "disabled" }
);
}
LlmRequestFormat::Messages if enabled => {
assert_eq!(body["thinking"]["type"], "adaptive");
}
_ => assert!(
body.get("thinking").is_none(),
"{preset} {endpoint} {model} {format:?}"
),
}
assert!(body.get("reasoning_effort").is_none());
assert!(body.get("enable_thinking").is_none());
if format == LlmRequestFormat::Responses {
assert_eq!(
body["reasoning"]["effort"],
if enabled { "medium" } else { "low" }
);
assert!(body.get("messages").is_none());
} else {
assert!(body.get("reasoning").is_none());
}
}
}
}
}
}
fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) {
stream
.write_all(
@@ -2393,6 +2553,86 @@ mod tests {
stream.write_all(b"0\r\n\r\n").unwrap();
}
#[tokio::test]
async fn protocol_stream_errors_and_cancellation_keep_already_emitted_text() {
let cancelled_provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new(
"custom",
"test",
"invalid endpoint",
"",
"test",
));
let error = cancelled_provider
.chat_completion_messages_streaming(
Vec::new(),
StreamingTimeouts::for_input(0),
|_| panic!("cancelled request emitted text"),
|| true,
)
.await
.unwrap_err();
assert!(matches!(error, LLMError::Network(ref message) if message == "cancelled"));
for (format, delta, terminal_error) in [
(
LlmRequestFormat::Responses,
r#"{"type":"response.output_text.delta","delta":"partial"}"#,
r#"{"type":"response.failed"}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}"#,
r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#,
),
] {
for cancel in [false, true] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
read_http_request(&mut stream);
let fixture = format!("data: {delta}\n\ndata: {terminal_error}\n\n");
write_chunked_sse_response(&mut stream, &[fixture.as_bytes()]);
});
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"custom",
"test",
format!("http://{address}"),
"",
"test",
)
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
}),
);
let cancelled = AtomicBool::new(false);
let output = std::sync::Mutex::new(String::new());
let result = provider
.chat_completion_messages_streaming(
vec![json!({"role":"user","content":"hi"})],
StreamingTimeouts::for_input(2),
|text| {
output.lock().unwrap().push_str(text);
cancelled.store(cancel, Ordering::SeqCst);
},
|| cancelled.load(Ordering::SeqCst),
)
.await;
assert_eq!(*output.lock().unwrap(), "partial");
if cancel {
assert_eq!(result.unwrap(), "partial");
} else {
assert!(result
.unwrap_err()
.to_string()
.contains("llmResponseIncomplete"));
}
server.join().unwrap();
}
}
}
/// 带间隔的 SSE 发送:每个 chunk 前先睡一段,用来模拟「思考很久才出字」和
/// 「出字中途卡死」两种真实流。
fn write_chunked_sse_response_with_delays(
@@ -62,7 +62,10 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
("codingPlanX", "codingPlanX"),
("minimax", "minimax"),
("stepfun", "stepfun"),
("custom", "custom"),
("opencode", "opencode"),
("custom", "customChatCompletions"),
("custom_responses", "customResponses"),
("custom_messages", "customMessages"),
];
const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[
@@ -133,6 +136,8 @@ pub struct ProviderDescriptor {
pub auth_requirement: AuthRequirement,
pub validation_probe: ValidationProbe,
pub static_models: Vec<String>,
pub default_request_format: Option<crate::llm_protocol::LlmRequestFormat>,
pub supported_request_formats: Vec<crate::llm_protocol::LlmRequestFormat>,
}
pub fn provider_descriptors(kind: ProviderKind) -> Vec<ProviderDescriptor> {
@@ -210,6 +215,16 @@ fn provider_descriptor_with_label(
),
};
Some(ProviderDescriptor {
default_request_format: (kind == ProviderKind::Llm
&& crate::llm_protocol::LlmRequestFormat::selectable(&id))
.then(|| crate::llm_protocol::LlmRequestFormat::default_for(&id)),
supported_request_formats: if kind == ProviderKind::Llm
&& crate::llm_protocol::LlmRequestFormat::selectable(&id)
{
crate::llm_protocol::LlmRequestFormat::ALL.to_vec()
} else {
Vec::new()
},
kind,
provider_type,
label_key: label_key.to_string(),
@@ -422,6 +437,8 @@ pub fn equivalent_endpoint(left: &str, right: &str) -> bool {
.trim()
.trim_end_matches('/')
.trim_end_matches("/chat/completions")
.trim_end_matches("/responses")
.trim_end_matches("/messages")
.trim_end_matches('/')
}
normalize(left).eq_ignore_ascii_case(normalize(right))
@@ -465,6 +482,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> {
pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
match provider_type {
"opencode" => Some("https://opencode.ai/zen/v1"),
"ark" => Some("https://ark.cn-beijing.volces.com/api/v3"),
"deepseek" => Some("https://api.deepseek.com/v1"),
"siliconflow" => Some("https://api.siliconflow.cn/v1"),
@@ -485,7 +503,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
pub fn default_llm_model(provider_type: &str) -> Option<&'static str> {
match provider_type {
"ark" => Some("deepseek-v3-2"),
"deepseek" => Some("deepseek-v4-flash"),
"deepseek" | "opencode" => Some("deepseek-v4-flash"),
"siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"),
"atlascloud" => Some("qwen/qwen3.5-flash"),
"openai" | "cometapi" => Some("gpt-4o"),
@@ -956,6 +974,42 @@ mod tests {
assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err());
}
#[test]
fn opencode_descriptor_supplies_defaults_formats_and_credentials() {
use crate::llm_protocol::LlmRequestFormat;
assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode"));
let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap();
assert_eq!(descriptor.label_key, "opencode");
assert_eq!(
descriptor.default_endpoint.as_deref(),
Some("https://opencode.ai/zen/v1")
);
assert_eq!(
descriptor.default_model.as_deref(),
Some("deepseek-v4-flash")
);
assert_eq!(
descriptor.default_request_format,
Some(LlmRequestFormat::ChatCompletions)
);
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText);
assert!(api_key_required(
ProviderKind::Llm,
"opencode",
Some("https://opencode.ai/zen/v1/chat/completions/")
));
let mut configuration = CredentialConfiguration {
llm_endpoint: true,
llm_endpoint_matches_default: true,
llm_model: true,
..CredentialConfiguration::default()
};
assert!(!llm_configured("opencode", &configuration));
configuration.llm_api_key = true;
assert!(llm_configured("opencode", &configuration));
}
#[test]
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
@@ -23,6 +23,7 @@ use crate::domains::{
ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest,
};
use crate::errors::{BackendError, BackendErrorCode};
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat};
use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine};
use crate::provider_rules::{
api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint,
@@ -158,6 +159,13 @@ impl ProviderService {
};
Ok(ResolvedProvider {
thinking_enabled: request.thinking_enabled,
protocol: if request.kind == ProviderKind::Llm {
LlmProtocolConfig::load(self.credentials.as_ref(), &provider_id, &provider_type)
.await?
} else {
LlmProtocolConfig::default()
},
kind: request.kind,
provider_id,
provider_type,
@@ -350,6 +358,8 @@ impl ProviderApi for ProviderService {
#[derive(Debug, Clone)]
struct ResolvedProvider {
thinking_enabled: bool,
protocol: LlmProtocolConfig,
kind: ProviderKind,
provider_id: String,
provider_type: String,
@@ -362,6 +372,7 @@ struct ResolvedProvider {
impl ResolvedProvider {
fn context(&self) -> DictationContext {
let mut context = DictationContext::default();
context.polish.llm_thinking_enabled = self.thinking_enabled;
let invocation = ProviderInvocation {
provider_id: self.provider_id.clone(),
provider_type: self.provider_type.clone(),
@@ -448,7 +459,9 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro
validate_provider_endpoint(endpoint, resolved.kind == ProviderKind::Asr)?;
}
if let Some(headers) = resolved.extra_headers.as_deref() {
parse_extra_headers(headers)?;
resolved
.protocol
.validate_headers(&parse_extra_headers(headers)?)?;
}
}
Ok(())
@@ -585,6 +598,19 @@ fn sanitize_validation_error(error: BackendError) -> BackendError {
return error;
}
let message = error.message.as_str();
for code in [
"llmResponseIncomplete",
"llmStreamError",
"llmRequestFormatInvalid",
"llmThinkingModeInvalid",
"llmTokenLimitInvalid",
"llmThinkingBudgetInvalid",
"llmProtocolHeaderConflict",
] {
if message == code || message == format!("parse error: {code}") {
return provider_error(code);
}
}
if message.ends_with("is not configured") {
return error;
}
@@ -620,8 +646,7 @@ async fn fetch_models(
.or_else(|| default_omni_endpoint(&resolved.provider_type))
.ok_or_else(|| provider_error("provider endpoint is not configured"))?;
let url = models_url(endpoint)?;
let is_gemini =
crate::net::sanitized_url_for_logs(&url).contains("generativelanguage.googleapis.com");
let is_gemini = resolved.provider_type == "gemini";
let mut request_headers = Vec::new();
if let Some(api_key) = resolved
.api_key
@@ -631,9 +656,16 @@ async fn fetch_models(
if is_gemini {
request_headers.push(("x-goog-api-key".to_string(), api_key.to_string()));
} else {
request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}")));
request_headers.extend(resolved.protocol.format.headers(api_key));
}
}
if resolved.protocol.format == LlmRequestFormat::Messages
&& !request_headers
.iter()
.any(|(name, _)| name == "anthropic-version")
{
request_headers.extend(resolved.protocol.format.headers(""));
}
if let Some(extra_headers) = resolved.extra_headers.as_deref() {
for (name, value) in parse_extra_headers(extra_headers)? {
request_headers.push((name, value));
@@ -710,18 +742,8 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result<Vec<String>, Backend
}
fn models_url(endpoint: &str) -> Result<String, BackendError> {
let mut url = url::Url::parse(endpoint.trim())
.map_err(|_| invalid_request("provider endpoint is invalid"))?;
let path = url.path().trim_end_matches('/');
let next_path = if path.ends_with("/models") {
path.to_string()
} else if let Some(prefix) = path.strip_suffix("/chat/completions") {
format!("{prefix}/models")
} else {
format!("{path}/models")
};
url.set_path(&next_path);
Ok(url.to_string())
crate::llm_protocol::endpoint_url(endpoint, "/models")
.map_err(|_| invalid_request("provider endpoint is invalid"))
}
fn map_transport_error(error: ProviderTransportError) -> BackendError {
@@ -866,6 +888,48 @@ mod tests {
id
}
#[tokio::test]
async fn validation_and_model_lists_use_channel_protocol_and_thinking() {
use crate::llm_protocol::*;
for (format, preset, sse, path) in [
("chat_completions", "opencode", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "/v1/chat/completions"),
("responses", "opencode", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"),
("messages", "opencode", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"),
("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"),
("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"),
] {
for enabled in [false, true] {
let (endpoint, request) = spawn_http_response("200 OK", "text/event-stream", sse);
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[
(LLM_ENDPOINT_ACCOUNT, &endpoint), (LLM_MODEL_ACCOUNT, "test"), (LLM_API_KEY_ACCOUNT, "fixture-key"),
(REQUEST_FORMAT_ACCOUNT, format),
]).await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
service.validate(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: enabled }).await.unwrap();
let request = request.recv_timeout(Duration::from_secs(2)).unwrap();
let request = String::from_utf8(request).unwrap();
assert!(request.starts_with(&format!("POST {path} ")));
let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); }
else if format == "messages" && enabled { assert_eq!(body["thinking"]["type"], "adaptive"); }
else { assert!(body.get("thinking").is_none()); }
}
let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#);
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[
(LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{}", if format == "chat_completions" { "chat/completions" } else { format })), (LLM_MODEL_ACCOUNT, "test"),
(LLM_API_KEY_ACCOUNT, "fixture-key"), (REQUEST_FORMAT_ACCOUNT, format),
]).await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
assert_eq!(service.list_models(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: false }).await.unwrap().models, vec!["model"]);
let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap().to_ascii_lowercase();
assert!(request.starts_with("get /v1/models "));
if format == "messages" { assert!(request.contains("x-api-key: fixture-key")); }
else { assert!(request.contains("authorization: bearer fixture-key")); }
}
}
#[tokio::test]
async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() {
let (endpoint, request) =
@@ -885,6 +949,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -918,6 +983,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -951,6 +1017,7 @@ mod tests {
let error = service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -981,6 +1048,7 @@ mod tests {
let result = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -1024,6 +1092,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -1092,6 +1161,7 @@ mod tests {
let (service, credentials) = service_with_channel().await;
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some("missing".to_string()),
})
@@ -1108,6 +1178,7 @@ mod tests {
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
let error = service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Omni,
channel_id: Some("channel".to_string()),
})
@@ -1166,6 +1237,7 @@ mod tests {
let first_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(first_id.clone()),
})
@@ -1173,6 +1245,7 @@ mod tests {
.unwrap();
let second_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(second_id.clone()),
})
@@ -1180,6 +1253,7 @@ mod tests {
.unwrap();
let active_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: None,
})
@@ -1286,6 +1360,7 @@ mod tests {
let result = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -1327,6 +1402,7 @@ mod tests {
transport.push_response(status, br#"{"data":[]}"#);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1340,6 +1416,7 @@ mod tests {
transport.push_response(200, br#"not-json secret-body"#);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1351,6 +1428,7 @@ mod tests {
transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1389,6 +1467,7 @@ mod tests {
transport.push_error(transport_error);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1409,6 +1488,7 @@ mod tests {
let error = service
.list_models_with_cancellation(
ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
},
@@ -1441,6 +1521,7 @@ mod tests {
let error = service
.list_models_with_cancellation(
ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
},
@@ -1490,6 +1571,8 @@ mod tests {
];
for (provider_type, expected_models) in expected {
let resolved = ResolvedProvider {
thinking_enabled: false,
protocol: LlmProtocolConfig::default(),
kind: ProviderKind::Asr,
provider_id: provider_type.to_string(),
provider_type: provider_type.to_string(),
@@ -565,6 +565,10 @@ pub struct UserPreferences {
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
#[serde(default)]
pub update_channel: UpdateChannel,
/// 是否由用户明确选择过更新渠道。旧版默认会把 Stable 写入配置,单看
/// `update_channel` 无法区分默认值与主动切换;历史 Beta 则必然来自用户 opt-in。
#[serde(default)]
pub update_channel_explicit: bool,
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。
/// 写入新条目时执行清理,避免后台轮询。
#[serde(default = "default_history_retention_days")]
@@ -892,6 +896,8 @@ struct UserPreferencesWire {
sherpa_onnx_keep_loaded_secs: u32,
#[serde(default)]
update_channel: UpdateChannel,
#[serde(default)]
update_channel_explicit: Option<bool>,
#[serde(default = "default_history_retention_days")]
history_retention_days: u32,
#[serde(default = "default_polish_context_window_minutes")]
@@ -1061,6 +1067,8 @@ impl Default for UserPreferencesWire {
sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
update_channel: prefs.update_channel,
// None 保留旧配置缺少标记的信息;反序列化时只有历史 Beta 视为显式选择。
update_channel_explicit: None,
history_retention_days: prefs.history_retention_days,
polish_context_window_minutes: prefs.polish_context_window_minutes,
start_minimized: prefs.start_minimized,
@@ -1129,6 +1137,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
};
let (local_asr_active_model, local_whisper_active_model) =
migrate_local_asr_models(wire.local_asr_active_model, wire.local_whisper_active_model);
let update_channel_explicit = wire
.update_channel_explicit
.unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta));
Ok(Self {
hotkey: wire.hotkey,
@@ -1224,6 +1235,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
update_channel: wire.update_channel,
update_channel_explicit,
history_retention_days: wire.history_retention_days,
polish_context_window_minutes: wire.polish_context_window_minutes,
start_minimized: wire.start_minimized,
@@ -1565,6 +1577,7 @@ impl Default for UserPreferences {
sherpa_onnx_language_hint: String::new(),
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
update_channel: UpdateChannel::default(),
update_channel_explicit: false,
history_retention_days: default_history_retention_days(),
polish_context_window_minutes: default_polish_context_window_minutes(),
start_minimized: false,
@@ -3133,6 +3146,32 @@ mod tests {
assert!(!prefs.streaming_insert_save_clipboard);
}
#[test]
fn update_channel_migration_preserves_only_explicit_legacy_beta_opt_in() {
let legacy_stable: UserPreferences =
serde_json::from_str(r#"{ "updateChannel": "stable" }"#).unwrap();
assert_eq!(legacy_stable.update_channel, UpdateChannel::Stable);
assert!(!legacy_stable.update_channel_explicit);
let legacy_beta: UserPreferences =
serde_json::from_str(r#"{ "updateChannel": "beta" }"#).unwrap();
assert_eq!(legacy_beta.update_channel, UpdateChannel::Beta);
assert!(legacy_beta.update_channel_explicit);
let explicit_stable: UserPreferences = serde_json::from_str(
r#"{
"updateChannel": "stable",
"updateChannelExplicit": true
}"#,
)
.unwrap();
assert!(explicit_stable.update_channel_explicit);
let round_trip: UserPreferences =
serde_json::from_str(&serde_json::to_string(&explicit_stable).unwrap()).unwrap();
assert!(round_trip.update_channel_explicit);
}
#[test]
fn paste_shortcut_round_trips_explicit_values() {
for (raw, expected) in [
@@ -1108,6 +1108,7 @@ mod tests {
.services()
.provider
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: None,
})
+2
View File
@@ -372,6 +372,7 @@ mod linux_app {
.services()
.provider
.list_models(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
@@ -2500,6 +2501,7 @@ mod linux_app {
.services()
.provider
.validate(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
+1
View File
@@ -4241,6 +4241,7 @@ dependencies = [
"regex",
"reqwest 0.12.28",
"rustls",
"semver",
"serde",
"serde_json",
"sha1",
+1
View File
@@ -30,6 +30,7 @@ tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
semver = "1"
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
base64 = "0.22"
sha2 = "0.10"
@@ -22,25 +22,13 @@ pub fn map_abi_to_arch(abi: &str) -> &'static str {
}
pub fn version_is_newer(remote: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u32> {
v.split(|c| c == '.' || c == '-')
.filter_map(|p| p.parse().ok())
.collect()
}
let remote_parts = parts(remote);
let current_parts = parts(current);
let max = remote_parts.len().max(current_parts.len());
for i in 0..max {
let r = remote_parts.get(i).copied().unwrap_or(0);
let c = current_parts.get(i).copied().unwrap_or(0);
if r > c {
return true;
}
if r < c {
return false;
}
}
false
let (Ok(remote), Ok(current)) = (
semver::Version::parse(remote),
semver::Version::parse(current),
) else {
return false;
};
remote > current
}
pub fn stable_manifest_urls(arch: &str) -> Vec<String> {
@@ -78,9 +66,12 @@ mod tests {
}
#[test]
fn version_is_newer_handles_beta_suffix() {
assert!(version_is_newer("1.3.8-1", "1.3.8"));
assert!(!version_is_newer("1.3.8", "1.3.8-1"));
fn version_is_newer_uses_semver_prerelease_ordering() {
assert!(version_is_newer("1.3.18", "1.3.18-Beta.7"));
assert!(version_is_newer("1.3.18-Beta.8", "1.3.18-Beta.7"));
assert!(!version_is_newer("1.3.18-Beta.7", "1.3.18-Beta.7"));
assert!(!version_is_newer("1.3.17", "1.3.18-Beta.7"));
assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7"));
}
#[test]
@@ -250,6 +250,11 @@ fn read_vault_credential(
key: &openless_core::CredentialKey,
) -> Result<Option<String>, openless_core::BackendError> {
let result = match (key.namespace, key.account.as_str()) {
(openless_core::CredentialNamespace::Llm, account)
if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) =>
{
CredentialsVault::get_llm_protocol_option(key.provider_id.as_deref(), account)
}
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
match key.provider_id.as_deref() {
Some(provider) => serde_json::to_string(
@@ -316,6 +321,11 @@ fn write_vault_credential(
value: &str,
) -> Result<(), openless_core::BackendError> {
let result = match (key.namespace, key.account.as_str()) {
(openless_core::CredentialNamespace::Llm, account)
if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) =>
{
CredentialsVault::set_llm_protocol_option(key.provider_id.as_deref(), account, value)
}
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
match key.provider_id.as_deref() {
Some(provider) => {
@@ -745,6 +755,9 @@ fn credential_key(
provider: Option<String>,
) -> Result<openless_core::CredentialKey, String> {
let namespace = match account {
account if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => {
openless_core::CredentialNamespace::Llm
}
LLM_EXTRA_HEADERS_ACCOUNT | LLM_TEMPERATURE_ACCOUNT => {
openless_core::CredentialNamespace::Llm
}
@@ -860,6 +873,11 @@ mod tests {
#[test]
fn core_llm_accounts_are_supported_by_the_tauri_vault_adapter() {
for account in openless_core::llm_protocol::CONFIG_ACCOUNTS {
let key = credential_key(account, Some("channel-b".into())).unwrap();
assert_eq!(key.namespace, openless_core::CredentialNamespace::Llm);
assert_eq!(key.provider_id.as_deref(), Some("channel-b"));
}
for account in [
openless_core::credentials::LLM_API_KEY_ACCOUNT,
openless_core::credentials::LLM_MODEL_ACCOUNT,
@@ -29,7 +29,11 @@ pub async fn validate_provider_credentials(
let kind = parse_provider_kind(&kind)?;
core.services()
.provider
.validate(openless_core::ProviderRequest { kind, channel_id })
.validate(openless_core::ProviderRequest {
kind,
channel_id,
thinking_enabled: core.get_preferences().llm_thinking_enabled,
})
.await
.map_err(|error| error.message)
}
@@ -43,7 +47,11 @@ pub async fn list_provider_models(
let kind = parse_provider_kind(&kind)?;
core.services()
.provider
.list_models(openless_core::ProviderRequest { kind, channel_id })
.list_models(openless_core::ProviderRequest {
kind,
channel_id,
thinking_enabled: core.get_preferences().llm_thinking_enabled,
})
.await
.map_err(|error| error.message)
}
@@ -2,7 +2,9 @@ use super::*;
#[tauri::command]
pub fn get_settings(core: CoreState<'_>) -> UserPreferences {
core.get_preferences()
let mut prefs = core.get_preferences();
prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION"));
prefs
}
#[tauri::command]
@@ -143,8 +145,10 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> {
}
}
pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
fn persist_settings_with_host_lock_held(
coord: &Coordinator,
prefs: UserPreferences,
) -> Result<(), String> {
coord
.backend()
.update_settings(
@@ -163,11 +167,22 @@ pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> R
.map_err(|error| error.to_string())
}
pub(crate) fn persist_strict_settings(
fn persist_settings_preserving_update_channel(
coord: &Coordinator,
prefs: UserPreferences,
mut prefs: UserPreferences,
) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
// 在同一把写锁内读取并回填,避免并发渠道切换被旧设置快照覆盖。
preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences());
persist_settings_with_host_lock_held(coord, prefs)
}
pub(crate) fn persist_strict_settings(
coord: &Coordinator,
mut prefs: UserPreferences,
) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences());
coord
.backend()
.update_settings(
@@ -179,6 +194,21 @@ pub(crate) fn persist_strict_settings(
.map_err(|error| error.to_string())
}
async fn invalidate_llm_tests_if_thinking_changed(
coord: &Coordinator,
previous: &UserPreferences,
next: &UserPreferences,
) -> Result<(), String> {
if previous.llm_thinking_enabled != next.llm_thinking_enabled {
coord
.backend()
.invalidate_channel_tests(openless_core::ChannelKind::Llm)
.await
.map_err(|error| error.to_string())?;
}
Ok(())
}
#[cfg(not(mobile))]
#[tauri::command]
pub async fn set_settings(
@@ -194,10 +224,11 @@ pub async fn set_settings(
.map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
invalidate_llm_tests_if_thinking_changed(&coord, &remote_prev, &prefs).await?;
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs)?;
persist_settings_preserving_update_channel(&*coord, prefs)?;
let prefs = coord.backend().get_preferences();
// 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule
// 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
@@ -245,7 +276,10 @@ pub async fn set_settings(
#[cfg(mobile)]
#[tauri::command]
pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> Result<(), String> {
pub async fn set_settings(
coord: CoordinatorState<'_>,
mut prefs: UserPreferences,
) -> Result<(), String> {
let previous = coord.backend().get_preferences();
let packs = coord
.backend()
@@ -253,7 +287,8 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) ->
.map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
persist_settings(&*coord, prefs)?;
invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?;
persist_settings_preserving_update_channel(&*coord, prefs)?;
let prefs = coord.backend().get_preferences();
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
coord.sync_capsule_style_from_preferences();
@@ -293,6 +328,63 @@ mod tests {
);
assert_eq!(stale_settings_payload.default_mode, PolishMode::Light);
}
#[test]
fn update_channel_defaults_to_build_channel_until_user_selects_one() {
let mut prefs = UserPreferences::default();
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0-Beta.1"),
UpdateChannel::Beta
);
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0"),
UpdateChannel::Stable
);
let legacy_beta = UserPreferences {
update_channel: UpdateChannel::Beta,
update_channel_explicit: true,
..UserPreferences::default()
};
assert_eq!(
effective_update_channel(None, &legacy_beta, "2.0.0"),
UpdateChannel::Beta
);
assert_eq!(
effective_update_channel(Some(UpdateChannel::Stable), &prefs, "2.0.0-Beta.1"),
UpdateChannel::Stable
);
assert!(select_update_channel(&mut prefs, UpdateChannel::Stable));
assert!(prefs.update_channel_explicit);
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0-Beta.1"),
UpdateChannel::Stable
);
assert!(!select_update_channel(&mut prefs, UpdateChannel::Stable));
assert!(select_update_channel(&mut prefs, UpdateChannel::Beta));
assert_eq!(prefs.update_channel, UpdateChannel::Beta);
assert!(prefs.update_channel_explicit);
}
#[test]
fn general_settings_save_preserves_dedicated_update_channel_fields() {
let current = UserPreferences {
update_channel: UpdateChannel::Stable,
update_channel_explicit: true,
..UserPreferences::default()
};
let mut stale_payload = UserPreferences {
update_channel: UpdateChannel::Beta,
update_channel_explicit: false,
..UserPreferences::default()
};
preserve_update_channel_preferences(&mut stale_payload, &current);
assert_eq!(stale_payload.update_channel, UpdateChannel::Stable);
assert!(stale_payload.update_channel_explicit);
}
}
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
@@ -308,9 +400,38 @@ mod tests {
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
// Release assets 里物理分离)。
fn effective_update_channel(
requested: Option<UpdateChannel>,
prefs: &UserPreferences,
app_version: &str,
) -> UpdateChannel {
requested.unwrap_or_else(|| {
if prefs.update_channel_explicit {
prefs.update_channel
} else if app_version.contains('-') {
UpdateChannel::Beta
} else {
UpdateChannel::Stable
}
})
}
fn select_update_channel(prefs: &mut UserPreferences, channel: UpdateChannel) -> bool {
let changed = prefs.update_channel != channel || !prefs.update_channel_explicit;
prefs.update_channel = channel;
prefs.update_channel_explicit = true;
changed
}
fn preserve_update_channel_preferences(incoming: &mut UserPreferences, current: &UserPreferences) {
incoming.update_channel = current.update_channel;
incoming.update_channel_explicit = current.update_channel_explicit;
}
#[tauri::command]
pub fn get_update_channel(core: CoreState<'_>) -> UpdateChannel {
core.get_preferences().update_channel
let prefs = core.get_preferences();
effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION"))
}
#[tauri::command]
@@ -318,12 +439,13 @@ pub fn set_update_channel(
coord: CoordinatorState<'_>,
channel: UpdateChannel,
) -> Result<(), String> {
// 渠道读取和持久化必须同属一个写临界区,避免反向覆盖并发常规设置。
let _host_guard = coord.lock_settings_host();
let mut prefs = coord.backend().get_preferences();
if prefs.update_channel == channel {
if !select_update_channel(&mut prefs, channel) {
return Ok(());
}
prefs.update_channel = channel;
persist_settings(&*coord, prefs)?;
persist_settings_with_host_lock_held(&*coord, prefs)?;
Ok(())
}
@@ -461,7 +583,7 @@ pub struct AppUpdateMetadata {
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
/// 不传则使用用户明确选择的渠道;尚未选择时跟随当前构建类型。
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
#[tauri::command]
#[cfg(not(mobile))]
@@ -473,7 +595,8 @@ pub async fn app_check_update_with_channel<R: tauri::Runtime>(
) -> Result<Option<AppUpdateMetadata>, String> {
use tauri_plugin_updater::UpdaterExt;
let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel);
let prefs = coord.backend().get_preferences();
let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION"));
let mut builder = webview.updater_builder();
if let Some(ms) = timeout_ms {
builder = builder.timeout(std::time::Duration::from_millis(ms));
@@ -537,7 +660,8 @@ pub async fn app_check_update_with_channel(
) -> Result<Option<AppUpdateMetadata>, String> {
#[cfg(target_os = "android")]
{
let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel);
let prefs = coord.backend().get_preferences();
let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION"));
return crate::android::updater::check_update(channel).await;
}
#[cfg(not(target_os = "android"))]
@@ -383,9 +383,28 @@ struct CredsLlmEntry {
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
extraHeaders: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
requestFormat: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
messagesThinking: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
maxTokens: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thinkingBudget: Option<String>,
}
impl CredsLlmEntry {
fn protocol_option(&mut self, account: &str) -> Result<&mut Option<String>> {
use openless_core::llm_protocol::*;
match account {
REQUEST_FORMAT_ACCOUNT => Ok(&mut self.requestFormat),
MESSAGES_THINKING_ACCOUNT => Ok(&mut self.messagesThinking),
MAX_TOKENS_ACCOUNT => Ok(&mut self.maxTokens),
THINKING_BUDGET_ACCOUNT => Ok(&mut self.thinkingBudget),
_ => anyhow::bail!("unsupported LLM protocol option"),
}
}
fn is_empty(&self) -> bool {
// 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。
if self.channel.providerType.is_some() {
@@ -401,6 +420,10 @@ impl CredsLlmEntry {
&& self.baseURL.as_deref().unwrap_or("").is_empty()
&& self.model.as_deref().unwrap_or("").is_empty()
&& self.temperature.is_none()
&& self.requestFormat.is_none()
&& self.messagesThinking.is_none()
&& self.maxTokens.is_none()
&& self.thinkingBudget.is_none()
&& self
.extraHeaders
.as_ref()
@@ -2386,6 +2409,29 @@ impl CredentialsVault {
save_credentials(&root)
}
pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
let mut root = load_credentials_for_update()?;
let id = id.unwrap_or(&root.active.llm).to_string();
match root.providers.llm.get_mut(&id) {
Some(entry) => Ok(entry.protocol_option(account)?.clone()),
None => Ok(None),
}
}
pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut config = openless_core::llm_protocol::LlmProtocolConfig::default();
config.apply(account, value)?;
let mut root = load_credentials_for_update()?;
let id = id.unwrap_or(&root.active.llm).to_string();
let entry = root.providers.llm.entry(id).or_default();
*entry.protocol_option(account)? =
(!value.trim().is_empty()).then(|| value.trim().to_string());
entry.channel.lastTest = None;
save_credentials(&root)
}
/// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。
pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
@@ -2695,6 +2741,48 @@ mod tests {
.all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS));
}
#[test]
fn llm_protocol_options_survive_vault_reload_and_core_projection() {
use openless_core::llm_protocol::*;
let mut root = CredsRoot::default();
let entry = root.providers.llm.entry("channel-b".into()).or_default();
let values = ["messages", "budget", "8192", "2048"];
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
*entry.protocol_option(account).unwrap() = Some(value.into());
}
assert!(!entry.has_no_content());
let serialized = serde_json::to_string(&root).unwrap();
let mut restored: CredsRoot = serde_json::from_str(&serialized).unwrap();
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
assert_eq!(
restored
.providers
.llm
.get_mut("channel-b")
.unwrap()
.protocol_option(account)
.unwrap()
.as_deref(),
Some(value)
);
}
let decoded =
openless_core::credentials_legacy::decode_legacy_credentials(&serialized).unwrap();
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
assert!(decoded
.secrets
.iter()
.any(
|(key, secret)| key.provider_id.as_deref() == Some("channel-b")
&& key.account == account
&& secret.expose_secret() == value
));
}
let old: CredsLlmEntry = serde_json::from_str(r#"{"apiKey":"old-key"}"#).unwrap();
assert!(old.requestFormat.is_none());
assert!(!restored.providers.llm.contains_key("channel-a"));
}
#[test]
fn omni_accounts_route_to_omni_namespace_only() {
// 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902):
+28 -2
View File
@@ -19,9 +19,11 @@ import {
logClientError,
openExternal,
restartApp,
setUpdateChannel,
type AppUpdateMetadata,
type UpdateChannel,
} from '../lib/ipc';
import { isStableChannelSwitch } from '../lib/appVersion';
import { Btn } from '../pages/_atoms';
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
@@ -50,6 +52,7 @@ export type CheckUpdateOptions = {
export interface UseAutoUpdate {
status: UpdateStatus;
currentVersion: string;
version: string;
progress: number | null;
downloaded: number;
@@ -72,6 +75,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const updateRef = useRef<Update | null>(null);
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
const [status, setStatus] = useState<UpdateStatus>('idle');
const [currentVersion, setCurrentVersion] = useState('');
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
const [contentLength, setContentLength] = useState<number | null>(null);
@@ -143,6 +147,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => {
setStatus('checking');
setCurrentVersion('');
setVersion('');
setErrorMessage(null);
resetProgress();
@@ -157,6 +162,7 @@ export function useAutoUpdate(): UseAutoUpdate {
setStatus('none');
return;
}
setCurrentVersion(metadata.currentVersion);
if (isAndroid()) {
storeAndroidMetadata(metadata);
setVersion(metadata.version);
@@ -205,12 +211,18 @@ export function useAutoUpdate(): UseAutoUpdate {
};
const installUpdate = async () => {
const persistStableChannelSwitch = () =>
isStableChannelSwitch(currentVersion, version)
? setUpdateChannel('stable')
: Promise.resolve();
if (isAndroid()) {
const payload = androidUpdateRef.current;
if (!payload) return;
resetProgress();
setStatus('downloading');
try {
await persistStableChannelSwitch();
await appDownloadAndInstallAndroidUpdate(payload);
androidUpdateRef.current = null;
setStatus('downloaded');
@@ -229,6 +241,7 @@ export function useAutoUpdate(): UseAutoUpdate {
resetProgress();
setStatus('downloading');
try {
await persistStableChannelSwitch();
await update.download((event: DownloadEvent) => {
if (event.event === 'Started') {
resetProgress();
@@ -257,12 +270,14 @@ export function useAutoUpdate(): UseAutoUpdate {
if (busy) return;
await closeUpdate();
setStatus('idle');
setCurrentVersion('');
setVersion('');
resetProgress();
};
return {
status,
currentVersion,
version,
progress,
downloaded,
@@ -290,6 +305,7 @@ export function isDialogStatus(
export function UpdateDialog({
status,
currentVersion,
version,
progress,
downloaded,
@@ -299,6 +315,7 @@ export function UpdateDialog({
onClose,
}: {
status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError';
currentVersion: string;
version: string;
progress: number | null;
downloaded: number;
@@ -312,6 +329,8 @@ export function UpdateDialog({
const installing = status === 'installing';
const installError = status === 'installError';
const androidInstalled = isAndroid() && status === 'downloaded';
const switchingToStable =
status === 'available' && isStableChannelSwitch(currentVersion, version);
// Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change,
// 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗
// 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。
@@ -339,7 +358,9 @@ export function UpdateDialog({
}}
>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>
{t(`settings.about.updateDialog.${status}.title`)}
{t(
`settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`,
)}
</div>
<div
style={{
@@ -361,7 +382,12 @@ export function UpdateDialog({
? t('settings.about.updateDialog.installError.desc', {
error: errorMessage || t('settings.about.updateError'),
})
: t(`settings.about.updateDialog.${status}.desc`, { version })}
: switchingToStable
? t('settings.about.updateDialog.stableChannelSwitch.desc', {
currentVersion,
version,
})
: t(`settings.about.updateDialog.${status}.desc`, { version })}
</div>
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
@@ -55,6 +55,7 @@ export function AutoUpdateGate() {
return (
<UpdateDialog
status={u.status}
currentVersion={u.currentVersion}
version={u.version}
progress={u.progress}
downloaded={u.downloaded}
@@ -42,8 +42,7 @@ import { useAppState, type AppTab } from '../state/useAppState';
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections'];
const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace'];
/** macOS 上侧栏顶部需让开原生红绿灯的高度。红绿灯在 (20,20),按钮组高约 14px,
* 上下留等距 20px → 避让带总高 54px,红绿灯恰好竖直居中、左缘与导航图标列对齐。 */
/** Reserve the native traffic-light strip before the sidebar's version row. */
const MAC_TRAFFIC_LIGHT_CLEARANCE = 44;
const SIDEBAR_WIDTH = 226;
+31 -1
View File
@@ -1274,6 +1274,10 @@ export const de: typeof zhCN = {
pipelineIsolationNotice:
'Die beiden Modi speichern ihre Zugangsdaten vollständig getrennt. Beim Wechsel bleiben die Daten des anderen Modus gespeichert und werden beim Zurückwechseln wieder verwendet.',
presets: {
opencode: 'OpenCode Zen',
customChatCompletions: 'Benutzerdefiniert · Chat Completions',
customResponses: 'Benutzerdefiniert · Responses',
customMessages: 'Benutzerdefiniert · Messages',
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
siliconflow: 'SiliconFlow',
@@ -1377,8 +1381,30 @@ export const de: typeof zhCN = {
thinkingModeLabel: 'Denkmodus',
thinkingModeOn: 'Ein',
thinkingModeOff: 'Aus',
requestFormatLabel: 'Anfrageformat',
messagesThinkingLabel: 'Denkmodus',
thinkingAdaptive: 'Adaptiv',
thinkingBudget: 'Festes Budget',
maxTokensLabel: 'Maximale Ausgabetokens',
thinkingBudgetLabel: 'Tokenbudget für das Denken',
responsesThinkingHint:
'Bei einigen Modellen lässt sich das Denken nur reduzieren, nicht abschalten. Anfragen mit Denken senden keinen Temperaturparameter.',
messagesThinkingHint:
'Ältere Modelle oder kompatible Gateways benötigen möglicherweise ein festes Budget unterhalb der Ausgabegrenze. Anfragen mit Denken senden keinen Temperaturparameter.',
llmRequestFormatInvalid: 'Ungültiges Anfrageformat. Wähle ein unterstütztes Format.',
llmThinkingModeInvalid: 'Ungültiger Denkmodus. Wähle einen unterstützten Modus.',
llmTokenLimitInvalid: 'Tokenlimits müssen positive ganze Zahlen sein.',
llmThinkingBudgetInvalid:
'Das Denkbudget muss mindestens 1024 betragen und im festen Modus unter der Ausgabegrenze liegen.',
llmResponseIncomplete:
'Die Antwort ist unvollständig oder hat die Ausgabegrenze erreicht. Bereits ausgegebener Text bleibt erhalten.',
llmProtocolHeaderConflict:
'Messages setzt die Authentifizierungs- und Versionsheader automatisch. Entferne x-api-key und anthropic-version aus den zusätzlichen Headern.',
llmStreamError:
'Der Server hat einen Streamfehler gemeldet. Prüfe das Modell und die Anfrageparameter.',
saveProtocol: 'Protokolleinstellungen speichern',
thinkingModeHint:
'„Aus“ deaktiviert oder minimiert das Nachdenken mit offiziellen Anbieterparametern. „Ein“ verwendet die Vorgaben des Kanals. Ohne zusätzliche Prompt-Anweisungen oder Anpassungen für einzelne Modelle.',
'Aktiviere, deaktiviere oder reduziere das Denken mit den vom Anfrageformat und Modell unterstützten Parametern. Dem Prompt werden keine Steueranweisungen hinzugefügt.',
bailianVocabularyIdLabel: 'Wörterbuch-ID für Begriffe (optional)',
bailianVocabularyIdNote:
'Wenn du bei DashScope ein Begriffswörterbuch erstellt hast, gib seine vocab-... ID ein. Leer lassen, um keine Begriffe zu übergeben.',
@@ -1804,6 +1830,10 @@ export const de: typeof zhCN = {
'Neueste Beta prüfen und herunterladen, anschließend den Updatedialog anzeigen',
betaChannelChecking: 'Wird geprüft…',
updateDialog: {
stableChannelSwitch: {
title: 'Zum stabilen Kanal wechseln',
desc: 'Aktuelle Version: OpenLess {{currentVersion}}\nZielversion: OpenLess {{version}}\nDadurch wechselst du vom Beta-Kanal zum stabilen Kanal. Fortfahren?',
},
available: {
title: 'Update verfügbar',
desc: 'OpenLess {{version}} ist verfügbar. Jetzt aktualisieren?',
+30 -1
View File
@@ -1256,6 +1256,10 @@ export const en: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
customChatCompletions: 'Custom · Chat Completions',
customResponses: 'Custom · Responses',
customMessages: 'Custom · Messages',
custom: 'Custom',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian realtime ASR',
@@ -1343,8 +1347,29 @@ export const en: typeof zhCN = {
thinkingModeLabel: 'Thinking',
thinkingModeOn: 'On',
thinkingModeOff: 'Off',
requestFormatLabel: 'Request format',
messagesThinkingLabel: 'Thinking mode',
thinkingAdaptive: 'Adaptive',
thinkingBudget: 'Fixed budget',
maxTokensLabel: 'Maximum output tokens',
thinkingBudgetLabel: 'Thinking budget tokens',
responsesThinkingHint:
'Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.',
messagesThinkingHint:
'Older models or compatible gateways may need a fixed budget below the output limit. Thinking requests omit temperature.',
llmRequestFormatInvalid: 'Invalid request format. Select a supported format.',
llmThinkingModeInvalid: 'Invalid thinking mode. Select a supported mode.',
llmTokenLimitInvalid: 'Token limits must be positive integers.',
llmThinkingBudgetInvalid:
'Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.',
llmResponseIncomplete:
'The response was incomplete or reached its output limit. Already emitted text is retained.',
llmProtocolHeaderConflict:
'Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.',
llmStreamError: 'The server returned a stream error. Check the model and request parameters.',
saveProtocol: 'Save protocol settings',
thinkingModeHint:
'Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.',
'Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.',
bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)',
bailianVocabularyIdNote:
'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.',
@@ -1751,6 +1776,10 @@ export const en: typeof zhCN = {
title: 'Update available',
desc: 'OpenLess {{version}} is available. Update now?',
},
stableChannelSwitch: {
title: 'Switch to Stable',
desc: 'Current version: OpenLess {{currentVersion}}\nTarget version: OpenLess {{version}}\nThis switches from the Beta channel to Stable. Continue?',
},
downloading: {
title: 'Downloading update',
desc: 'Downloading OpenLess {{version}}. Keep the app open.',
+31 -1
View File
@@ -1266,6 +1266,10 @@ export const es: typeof zhCN = {
pipelineIsolationNotice:
'Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.',
presets: {
opencode: 'OpenCode Zen',
customChatCompletions: 'Personalizado · Chat Completions',
customResponses: 'Personalizado · Responses',
customMessages: 'Personalizado · Messages',
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
siliconflow: 'SiliconFlow',
@@ -1368,8 +1372,30 @@ export const es: typeof zhCN = {
thinkingModeLabel: 'Razonamiento',
thinkingModeOn: 'Activado',
thinkingModeOff: 'Desactivado',
requestFormatLabel: 'Formato de solicitud',
messagesThinkingLabel: 'Modo de razonamiento',
thinkingAdaptive: 'Adaptativo',
thinkingBudget: 'Presupuesto fijo',
maxTokensLabel: 'Máximo de tokens de salida',
thinkingBudgetLabel: 'Presupuesto de tokens de razonamiento',
responsesThinkingHint:
'Algunos modelos solo permiten reducir el razonamiento, no desactivarlo. Las solicitudes de razonamiento omiten la temperatura.',
messagesThinkingHint:
'Los modelos antiguos o servicios compatibles pueden necesitar un presupuesto fijo inferior al límite de salida. Las solicitudes de razonamiento omiten la temperatura.',
llmRequestFormatInvalid: 'Formato de solicitud no válido. Selecciona uno compatible.',
llmThinkingModeInvalid: 'Modo de razonamiento no válido. Selecciona uno compatible.',
llmTokenLimitInvalid: 'Los límites de tokens deben ser números enteros positivos.',
llmThinkingBudgetInvalid:
'El presupuesto de razonamiento debe ser al menos 1024 y, en modo fijo, inferior al límite de salida.',
llmResponseIncomplete:
'La respuesta no se completó o alcanzó el límite de salida. Se conserva el texto ya mostrado.',
llmProtocolHeaderConflict:
'Messages establece automáticamente las cabeceras de autenticación y versión. Elimina x-api-key y anthropic-version de las cabeceras adicionales.',
llmStreamError:
'El servidor devolvió un error de transmisión. Comprueba el modelo y los parámetros de la solicitud.',
saveProtocol: 'Guardar ajustes del protocolo',
thinkingModeHint:
'Al desactivarlo, se reduce o deshabilita el razonamiento con los parámetros oficiales del proveedor. Al activarlo, se usan los valores predeterminados del canal. No se inyectan instrucciones ni se usan adaptadores por modelo.',
'Activa, desactiva o reduce el razonamiento mediante los parámetros admitidos por el formato y el modelo. No se añaden instrucciones de control al prompt.',
bailianVocabularyIdLabel: 'ID del vocabulario de palabras clave (opcional)',
bailianVocabularyIdNote:
'Si creaste un vocabulario en DashScope, introduce su ID vocab-... Déjalo vacío para no usar palabras clave.',
@@ -1788,6 +1814,10 @@ export const es: typeof zhCN = {
'Busca y descarga la última Beta y muestra el diálogo de actualización',
betaChannelChecking: 'Buscando…',
updateDialog: {
stableChannelSwitch: {
title: 'Cambiar al canal estable',
desc: 'Versión actual: OpenLess {{currentVersion}}\nVersión de destino: OpenLess {{version}}\nEsto cambia del canal beta al estable. ¿Continuar?',
},
available: {
title: 'Actualización disponible',
desc: 'OpenLess {{version}} está disponible. ¿Actualizar ahora?',
+33 -1
View File
@@ -1283,6 +1283,10 @@ export const fr: typeof zhCN = {
pipelineIsolationNotice:
'Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.',
presets: {
opencode: 'OpenCode Zen',
customChatCompletions: 'Personnalisé · Chat Completions',
customResponses: 'Personnalisé · Responses',
customMessages: 'Personnalisé · Messages',
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
siliconflow: 'SiliconFlow',
@@ -1386,8 +1390,32 @@ export const fr: typeof zhCN = {
thinkingModeLabel: 'Raisonnement',
thinkingModeOn: 'Activé',
thinkingModeOff: 'Désactivé',
requestFormatLabel: 'Format de requête',
messagesThinkingLabel: 'Mode de raisonnement',
thinkingAdaptive: 'Adaptatif',
thinkingBudget: 'Budget fixe',
maxTokensLabel: 'Nombre maximal de jetons en sortie',
thinkingBudgetLabel: 'Budget de jetons de raisonnement',
responsesThinkingHint:
'Certains modèles permettent seulement de réduire le raisonnement, pas de le désactiver. Les requêtes de raisonnement omettent la température.',
messagesThinkingHint:
'Les anciens modèles ou passerelles compatibles peuvent nécessiter un budget fixe inférieur à la limite de sortie. Les requêtes de raisonnement omettent la température.',
llmRequestFormatInvalid:
'Format de requête non valide. Sélectionnez un format pris en charge.',
llmThinkingModeInvalid:
'Mode de raisonnement non valide. Sélectionnez un mode pris en charge.',
llmTokenLimitInvalid: 'Les limites de jetons doivent être des entiers positifs.',
llmThinkingBudgetInvalid:
'Le budget de raisonnement doit être au moins de 1024 et, en mode fixe, inférieur à la limite de sortie.',
llmResponseIncomplete:
'La réponse est incomplète ou a atteint la limite de sortie. Le texte déjà affiché est conservé.',
llmProtocolHeaderConflict:
'Messages définit automatiquement les en-têtes d’authentification et de version. Retirez x-api-key et anthropic-version des en-têtes supplémentaires.',
llmStreamError:
'Le serveur a renvoyé une erreur de flux. Vérifiez le modèle et les paramètres de la requête.',
saveProtocol: 'Enregistrer les réglages du protocole',
thinkingModeHint:
'La désactivation réduit ou coupe le raisonnement via les paramètres officiels du fournisseur. L’activation utilise les valeurs par défaut du canal. Aucune injection d’instructions ni aucun adaptateur propre au modèle.',
'Activez, désactivez ou réduisez le raisonnement avec les paramètres pris en charge par le format et le modèle. Aucune instruction de contrôle n’est ajoutée au prompt.',
bailianVocabularyIdLabel: 'ID du vocabulaire de mots-clés (facultatif)',
bailianVocabularyIdNote:
'Si vous avez créé un vocabulaire dans DashScope, saisissez son ID vocab-... Laissez vide pour ne pas utiliser de mots-clés.',
@@ -1810,6 +1838,10 @@ export const fr: typeof zhCN = {
'Recherche et télécharge la dernière Beta, puis affiche le dialogue de mise à jour',
betaChannelChecking: 'Recherche…',
updateDialog: {
stableChannelSwitch: {
title: 'Passer au canal stable',
desc: 'Version actuelle : OpenLess {{currentVersion}}\nVersion cible : OpenLess {{version}}\nVous allez passer du canal bêta au canal stable. Continuer ?',
},
available: {
title: 'Mise à jour disponible',
desc: 'OpenLess {{version}} est disponible. Mettre à jour maintenant ?',
+29 -1
View File
@@ -1253,6 +1253,10 @@ export const ja: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: 'カスタム · Chat Completions',
customResponses: 'カスタム · Responses',
customMessages: 'カスタム · Messages',
custom: 'カスタム',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian リアルタイム ASR',
@@ -1340,8 +1344,28 @@ export const ja: typeof zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: 'オン',
thinkingModeOff: 'オフ',
requestFormatLabel: 'リクエスト形式',
messagesThinkingLabel: '思考方式',
thinkingAdaptive: '適応型',
thinkingBudget: '固定予算',
maxTokensLabel: '最大出力トークン数',
thinkingBudgetLabel: '思考トークン予算',
responsesThinkingHint:
'一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。',
messagesThinkingHint:
'旧モデルや互換ゲートウェイでは固定予算が必要な場合があります。予算は最大出力未満にしてください。思考時は温度を送信しません。',
llmRequestFormatInvalid: 'リクエスト形式が無効です。選択し直してください。',
llmThinkingModeInvalid: '思考方式が無効です。選択し直してください。',
llmTokenLimitInvalid: 'トークン上限は正の整数にしてください。',
llmThinkingBudgetInvalid: '思考予算は1024以上、固定予算では最大出力未満にしてください。',
llmResponseIncomplete: '応答が未完了か出力上限に達しました。出力済みテキストは保持されます。',
llmProtocolHeaderConflict:
'Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。',
llmStreamError:
'サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。',
saveProtocol: 'プロトコル設定を保存',
thinkingModeHint:
'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。',
'選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。',
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
bailianVocabularyIdNote:
'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
@@ -1733,6 +1757,10 @@ export const ja: typeof zhCN = {
title: '新しいバージョンがあります',
desc: 'OpenLess {{version}} が見つかりました。今すぐ更新しますか?',
},
stableChannelSwitch: {
title: '正式版に切り替える',
desc: '現在のバージョン:OpenLess {{currentVersion}}\n対象バージョン:OpenLess {{version}}\nBeta チャンネルから正式版に切り替えます。続行しますか?',
},
downloading: {
title: 'アップデートをダウンロード中',
desc: 'OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。',
+30 -1
View File
@@ -1245,6 +1245,10 @@ export const ko: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
customChatCompletions: '사용자 지정 · Chat Completions',
customResponses: '사용자 지정 · Responses',
customMessages: '사용자 지정 · Messages',
custom: '사용자 정의',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian 실시간 ASR',
@@ -1331,8 +1335,29 @@ export const ko: typeof zhCN = {
thinkingModeLabel: '사고',
thinkingModeOn: '켜짐',
thinkingModeOff: '꺼짐',
requestFormatLabel: '요청 형식',
messagesThinkingLabel: '사고 방식',
thinkingAdaptive: '적응형',
thinkingBudget: '고정 예산',
maxTokensLabel: '최대 출력 토큰',
thinkingBudgetLabel: '사고 토큰 예산',
responsesThinkingHint:
'일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.',
messagesThinkingHint:
'이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.',
llmRequestFormatInvalid: '잘못된 요청 형식입니다. 다시 선택하세요.',
llmThinkingModeInvalid: '잘못된 사고 방식입니다. 다시 선택하세요.',
llmTokenLimitInvalid: '토큰 한도는 양의 정수여야 합니다.',
llmThinkingBudgetInvalid:
'사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.',
llmResponseIncomplete:
'응답이 완료되지 않았거나 출력 한도에 도달했습니다. 이미 출력된 텍스트는 유지됩니다.',
llmProtocolHeaderConflict:
'Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.',
llmStreamError: '서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.',
saveProtocol: '프로토콜 설정 저장',
thinkingModeHint:
'꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.',
'선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.',
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
bailianVocabularyIdNote:
'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
@@ -1719,6 +1744,10 @@ export const ko: typeof zhCN = {
title: '새 버전 발견',
desc: 'OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?',
},
stableChannelSwitch: {
title: '정식 버전으로 전환',
desc: '현재 버전: OpenLess {{currentVersion}}\n대상 버전: OpenLess {{version}}\nBeta 채널에서 정식 버전으로 전환합니다. 계속하시겠습니까?',
},
downloading: {
title: '업데이트 다운로드 중',
desc: 'OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.',
+27 -1
View File
@@ -1197,6 +1197,10 @@ export const zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(阶跃星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: '自定义 · Chat Completions',
customResponses: '自定义 · Responses',
customMessages: '自定义 · Messages',
custom: '自定义',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里云百炼实时 ASR',
@@ -1280,8 +1284,26 @@ export const zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: '开启',
thinkingModeOff: '关闭',
requestFormatLabel: '请求格式',
messagesThinkingLabel: '思考方式',
thinkingAdaptive: '自适应',
thinkingBudget: '固定预算',
maxTokensLabel: '最大输出 tokens',
thinkingBudgetLabel: '思考预算 tokens',
responsesThinkingHint: '部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。',
messagesThinkingHint:
'旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。',
llmRequestFormatInvalid: '请求格式无效,请重新选择。',
llmThinkingModeInvalid: '思考方式无效,请重新选择。',
llmTokenLimitInvalid: 'Token 上限必须为正整数。',
llmThinkingBudgetInvalid: '思考预算至少为 1024,且固定预算必须小于最大输出。',
llmResponseIncomplete: '响应未完整结束或达到输出上限;已输出正文会保留。',
llmProtocolHeaderConflict:
'Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。',
llmStreamError: '服务端返回流式错误,请检查模型和请求参数。',
saveProtocol: '保存协议设置',
thinkingModeHint:
'关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。',
'按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。',
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
bailianModelRealtimeHint: '实时模型 · 边说边出字。',
@@ -1656,6 +1678,10 @@ export const zhCN = {
title: '发现新版本',
desc: '发现 OpenLess {{version}},是否现在更新?',
},
stableChannelSwitch: {
title: '切换到正式版',
desc: '当前版本:OpenLess {{currentVersion}}\n目标版本:OpenLess {{version}}\n这是从 Beta 渠道切换到正式版,是否继续?',
},
downloading: {
title: '正在下载更新',
desc: '正在下载 OpenLess {{version}},请保持应用打开。',
+27 -1
View File
@@ -1199,6 +1199,10 @@ export const zhTW: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
customChatCompletions: '自訂 · Chat Completions',
customResponses: '自訂 · Responses',
customMessages: '自訂 · Messages',
custom: '自定義',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里雲百煉即時 ASR',
@@ -1282,8 +1286,26 @@ export const zhTW: typeof zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: '開啟',
thinkingModeOff: '關閉',
requestFormatLabel: '請求格式',
messagesThinkingLabel: '思考方式',
thinkingAdaptive: '自適應',
thinkingBudget: '固定預算',
maxTokensLabel: '最大輸出 tokens',
thinkingBudgetLabel: '思考預算 tokens',
responsesThinkingHint: '部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度參數。',
messagesThinkingHint:
'舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度參數。',
llmRequestFormatInvalid: '請求格式無效,請重新選擇。',
llmThinkingModeInvalid: '思考方式無效,請重新選擇。',
llmTokenLimitInvalid: 'Token 上限必須為正整數。',
llmThinkingBudgetInvalid: '思考預算至少為 1024,且固定預算必須小於最大輸出。',
llmResponseIncomplete: '回應未完整結束或達到輸出上限;已輸出正文會保留。',
llmProtocolHeaderConflict:
'Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。',
llmStreamError: '伺服器回傳串流錯誤,請檢查模型和請求參數。',
saveProtocol: '儲存協定設定',
thinkingModeHint:
'關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。',
'依所選請求格式與模型支援的參數啟用、關閉或降低思考,不在提示詞注入控制指令。',
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
bailianModelRealtimeHint: '即時模型 · 邊說邊出字。',
@@ -1642,6 +1664,10 @@ export const zhTW: typeof zhCN = {
title: '發現新版本',
desc: '發現 OpenLess {{version}},是否現在更新?',
},
stableChannelSwitch: {
title: '切換到正式版',
desc: '目前版本:OpenLess {{currentVersion}}\n目標版本:OpenLess {{version}}\n這是從 Beta 頻道切換到正式版,是否繼續?',
},
downloading: {
title: '正在下載更新',
desc: '正在下載 OpenLess {{version}},請保持應用打開。',
@@ -0,0 +1,28 @@
import { en } from '../i18n/en';
import { ja } from '../i18n/ja';
import { ko } from '../i18n/ko';
import { zhCN } from '../i18n/zh-CN';
import { zhTW } from '../i18n/zh-TW';
import { isStableChannelSwitch } from './appVersion';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
assert(
isStableChannelSwitch('1.3.18-Beta.7', '1.3.18'),
'Beta → Stable should be a channel switch',
);
assert(
!isStableChannelSwitch('1.3.18-Beta.7', '1.3.18-Beta.8'),
'Beta → Beta should be a regular update',
);
assert(!isStableChannelSwitch('1.3.18', '1.3.19'), 'Stable → Stable should be a regular update');
for (const resources of [zhCN, zhTW, en, ja, ko]) {
const description = resources.settings.about.updateDialog.stableChannelSwitch.desc;
assert(description.includes('{{currentVersion}}'), 'switch copy should show the current version');
assert(description.includes('{{version}}'), 'switch copy should show the target version');
}
console.log('appVersion tests passed');
+3
View File
@@ -6,3 +6,6 @@ export const APP_VERSION_LABEL = `v${APP_VERSION}`;
// 例如 `1.2.24-1` / `1.2.24-2`;正式版没有 `-`,例如 `1.2.23` / `1.2.24`。
// 用于 UI 条件渲染——Beta 标签只在 Beta build 出现。
export const IS_BETA_BUILD = APP_VERSION.includes('-');
export const isStableChannelSwitch = (currentVersion: string, targetVersion: string) =>
currentVersion.includes('-') && !targetVersion.includes('-');
@@ -1,6 +1,7 @@
import type { CredentialsStatus } from '../types';
import { invokeOrMock } from './shared';
import { mockCredentialsStatus } from './mock-data';
import { mockCredentialsStatus, mockCredentialValues } from './mock-data';
import { invalidateMockChannelTest } from './channels';
export interface ProviderCheckResult {
ok: boolean;
@@ -15,7 +16,10 @@ export function getCredentials(): Promise<CredentialsStatus> {
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock('set_credential', { account, value, provider }, () => undefined);
return invokeOrMock('set_credential', { account, value, provider }, () => {
mockCredentialValues.set(`${provider ?? ''}:${account}`, value);
if (provider && account.startsWith('ark.')) invalidateMockChannelTest(provider);
});
}
export function setActiveAsrProvider(provider: string): Promise<void> {
@@ -31,7 +35,11 @@ export function setActiveOmniProvider(provider: string): Promise<void> {
}
export function readCredential(account: string, provider?: string): Promise<string | null> {
return invokeOrMock<string | null>('read_credential', { account, provider }, () => null);
return invokeOrMock<string | null>(
'read_credential',
{ account, provider },
() => mockCredentialValues.get(`${provider ?? ''}:${account}`) ?? null,
);
}
/** `channelId` 省略时测当前生效的渠道;卡片上的「测试连通」会带上那张卡片的 id。 */
+52 -5
View File
@@ -2,6 +2,7 @@
// Core 选择排序后的第一个启用渠道;凭据通过 readCredential/setCredential 按渠道 ID 读写。
import { invokeOrMock } from './shared';
import { mockCredentialValues } from './mock-data';
export type ChannelKind = 'llm' | 'asr';
@@ -82,13 +83,33 @@ export function listChannels(kind: ChannelKind): Promise<Channel[]> {
return invokeOrMock('list_channels', { kind }, () => mockChannels[kind]);
}
export function invalidateMockChannelTest(id: string): void {
const channel = mockChannels.llm.find((channel) => channel.id === id);
if (channel) channel.lastTest = null;
}
export function invalidateMockChannelTests(kind: ChannelKind): void {
for (const channel of mockChannels[kind]) channel.lastTest = null;
}
/** 返回后端分配的渠道 id。 */
export function createChannel(
kind: ChannelKind,
providerType: string,
name: string,
): Promise<string> {
return invokeOrMock('create_channel', { kind, providerType, name }, () => providerType);
return invokeOrMock('create_channel', { kind, providerType, name }, () => {
const id = `${providerType}-${Date.now()}-${mockChannels[kind].length}`;
mockChannels[kind].push({
id,
name,
providerType,
enabled: true,
order: mockChannels[kind].length,
lastTest: null,
});
return id;
});
}
/** 在已建好的草稿卡片上换供应商(单弹窗添加流程的常规操作)。 */
@@ -97,12 +118,31 @@ export function setChannelProviderType(
id: string,
providerType: string,
): Promise<void> {
return invokeOrMock('set_channel_provider_type', { kind, id, providerType }, () => undefined);
return invokeOrMock('set_channel_provider_type', { kind, id, providerType }, () => {
const channel = mockChannels[kind].find((channel) => channel.id === id);
if (channel && channel.providerType !== providerType) {
channel.providerType = providerType;
channel.lastTest = null;
if (kind === 'llm') mockCredentialValues.delete(`${id}:ark.request_format`);
}
});
}
/** 关闭添加弹窗时回收没填任何东西的草稿;返回是否真的删了。 */
export function deleteChannelIfBlank(kind: ChannelKind, id: string): Promise<boolean> {
return invokeOrMock('delete_channel_if_blank', { kind, id }, () => true);
return invokeOrMock('delete_channel_if_blank', { kind, id }, () => {
const channel = mockChannels[kind].find((channel) => channel.id === id);
const prefix = `${id}:`;
const hasCredentials = [...mockCredentialValues].some(
([key, value]) => key.startsWith(prefix) && value.length > 0,
);
if (!channel || channel.name.trim() || hasCredentials) return false;
mockChannels[kind] = mockChannels[kind].filter((channel) => channel.id !== id);
for (const key of mockCredentialValues.keys()) {
if (key.startsWith(prefix)) mockCredentialValues.delete(key);
}
return true;
});
}
export function renameChannel(kind: ChannelKind, id: string, name: string): Promise<void> {
@@ -110,7 +150,11 @@ export function renameChannel(kind: ChannelKind, id: string, name: string): Prom
}
export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
return invokeOrMock('delete_channel', { kind, id }, () => undefined);
return invokeOrMock('delete_channel', { kind, id }, () => {
mockChannels[kind] = mockChannels[kind].filter((channel) => channel.id !== id);
for (const key of mockCredentialValues.keys())
if (key.startsWith(`${id}:`)) mockCredentialValues.delete(key);
});
}
export function setChannelEnabled(kind: ChannelKind, id: string, enabled: boolean): Promise<void> {
@@ -142,5 +186,8 @@ export function recordChannelTest(
latencyMs: number | null,
error: string | null,
): Promise<void> {
return invokeOrMock('record_channel_test', { kind, id, ok, latencyMs, error }, () => undefined);
return invokeOrMock('record_channel_test', { kind, id, ok, latencyMs, error }, () => {
const channel = mockChannels[kind].find((channel) => channel.id === id);
if (channel) channel.lastTest = { ok, latencyMs, error, at: Math.floor(Date.now() / 1000) };
});
}
@@ -1,3 +1,4 @@
export const mockCredentialValues = new Map<string, string>();
import type {
ActivityDay,
CorrectionRule,
@@ -109,6 +110,7 @@ export let mockSettings: UserPreferences = {
startMinimized: false,
themeMode: 'system',
updateChannel: 'stable',
updateChannelExplicit: false,
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
@@ -5,28 +5,33 @@
"authRequirement": "volcengine",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrVolcengine",
"providerType": "volcengine",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence_allows_no_final"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.elevenlabs.io/v1",
"defaultModel": "scribe_v2",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrElevenLabs",
"providerType": "elevenlabs",
"staticModels": [
"scribe_v2"
],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
"defaultModel": "fun-asr-realtime",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrBailian",
"providerType": "bailian",
@@ -46,12 +51,14 @@
"fun-asr-mtl-2025-08-25",
"paraformer-v2"
],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
"defaultModel": "qwen3-asr-flash-realtime",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrBailianQwen3",
"providerType": "bailian-qwen3-realtime",
@@ -60,12 +67,14 @@
"qwen3-asr-flash-realtime-2026-02-10",
"qwen3-asr-flash-realtime-2025-10-27"
],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
"defaultModel": "fun-asr-flash-2026-06-15",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrBailianFunAsrFlash",
"providerType": "bailian-fun-asr-flash",
@@ -73,178 +82,213 @@
"qwen-audio-3.0-asr-flash",
"fun-asr-flash-2026-06-15"
],
"supportedRequestFormats": [],
"validationProbe": "asr_non_silent"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.siliconflow.cn/v1",
"defaultModel": "FunAudioLLM/SenseVoiceSmall",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrSiliconflow",
"providerType": "siliconflow",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.stepfun.com/v1",
"defaultModel": "stepaudio-2.5-asr",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrStepfun",
"providerType": "stepfun",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "stepfun_no_speech"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4",
"defaultModel": "glm-asr-2512",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrZhipu",
"providerType": "zhipu",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.groq.com/openai/v1",
"defaultModel": "whisper-large-v3-turbo",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrGroq",
"providerType": "groq",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "whisper-1",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrWhisper",
"providerType": "whisper",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://openrouter.ai/api/v1",
"defaultModel": "openai/whisper-large-v3-turbo",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrOpenrouter",
"providerType": "openrouter",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://zenmux.ai/api/v1",
"defaultModel": "qwen/qwen3-asr-flash",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrZenmux",
"providerType": "zenmux",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "endpoint_model_optional_api_key",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrOpenAiCompatible",
"providerType": "openai-compatible",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
"defaultModel": "mimo-v2.5-asr",
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrXiaomiMimo",
"providerType": "xiaomi-mimo-asr",
"staticModels": [
"mimo-v2.5-asr"
],
"supportedRequestFormats": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "xfyun",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrIflytek",
"providerType": "iflytek",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "asr_silence_allows_no_final"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrFoundryLocalWhisper",
"providerType": "foundry-local-whisper",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrLocalWhisper",
"providerType": "local-whisper",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrSherpaOnnxLocal",
"providerType": "sherpa-onnx-local",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrLocalQwen3Mlx",
"providerType": "local-qwen3-mlx",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrLocalQwen3C",
"providerType": "local-qwen3-c",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrLocalQwen3",
"providerType": "local-qwen3",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "asr",
"labelKey": "asrAppleSpeech",
"providerType": "apple-speech",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "unsupported"
}
],
@@ -253,66 +297,99 @@
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3",
"defaultModel": "deepseek-v3-2",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "ark",
"providerType": "ark",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.deepseek.com/v1",
"defaultModel": "deepseek-v4-flash",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "deepseek",
"providerType": "deepseek",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.siliconflow.cn/v1",
"defaultModel": "Qwen/Qwen2.5-7B-Instruct",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "siliconflow",
"providerType": "siliconflow",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.atlascloud.ai/v1",
"defaultModel": "qwen/qwen3.5-flash",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "atlascloud",
"providerType": "atlascloud",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "gpt-4o",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "openai",
"providerType": "openai",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
"defaultModel": "gemini-2.5-flash",
"defaultRequestFormat": null,
"kind": "llm",
"labelKey": "gemini",
"providerType": "gemini",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "o_auth",
"defaultEndpoint": null,
"defaultModel": "gpt-5.5",
"defaultRequestFormat": null,
"kind": "llm",
"labelKey": "codexOAuth",
"providerType": "codex_oauth",
@@ -322,86 +399,183 @@
"gpt-5.4",
"gpt-5.5"
],
"supportedRequestFormats": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
"defaultModel": "xiaomi/mimo-v2-flash",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "mimo",
"providerType": "mimo",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.cometapi.com/v1",
"defaultModel": "gpt-4o",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "cometapi",
"providerType": "cometapi",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://openrouter.ai/api/v1",
"defaultModel": "qwen/qwen3-coder:free",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "openrouterFree",
"providerType": "openrouterFree",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1",
"defaultModel": "qwen3-coder-plus",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "alibabaCoding",
"providerType": "alibabaCoding",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.codingplanx.ai/v1",
"defaultModel": "gpt-5-mini",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "codingPlanX",
"providerType": "codingPlanX",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.minimaxi.com/v1",
"defaultModel": "MiniMax-M3",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "minimax",
"providerType": "minimax",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.stepfun.com/v1",
"defaultModel": "step-1o-turbo-vision",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "stepfun",
"providerType": "stepfun",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://opencode.ai/zen/v1",
"defaultModel": "deepseek-v4-flash",
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "opencode",
"providerType": "opencode",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": "chat_completions",
"kind": "llm",
"labelKey": "custom",
"labelKey": "customChatCompletions",
"providerType": "custom",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": "responses",
"kind": "llm",
"labelKey": "customResponses",
"providerType": "custom_responses",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": "messages",
"kind": "llm",
"labelKey": "customMessages",
"providerType": "custom_messages",
"staticModels": [],
"supportedRequestFormats": [
"chat_completions",
"responses",
"messages"
],
"validationProbe": "llm_text"
}
],
@@ -410,40 +584,48 @@
"authRequirement": "api_key",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "gpt-4o-audio-preview",
"defaultRequestFormat": null,
"kind": "omni",
"labelKey": "omniOpenai",
"providerType": "openai",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
"defaultModel": "gemini-2.5-flash",
"defaultRequestFormat": null,
"kind": "omni",
"labelKey": "omniGemini",
"providerType": "gemini",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"defaultModel": "qwen3-omni-flash",
"defaultRequestFormat": null,
"kind": "omni",
"labelKey": "omniDashscope",
"providerType": "dashscope-omni",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": null,
"defaultModel": null,
"defaultRequestFormat": null,
"kind": "omni",
"labelKey": "custom",
"providerType": "custom",
"staticModels": [],
"supportedRequestFormats": [],
"validationProbe": "omni_text"
}
]
@@ -1,6 +1,7 @@
import { invokeOrMock, isTauri } from './shared';
export type ProviderKind = 'asr' | 'llm' | 'omni';
export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages';
export type AuthRequirement =
| 'none'
@@ -20,6 +21,8 @@ export interface ProviderDescriptor {
authRequirement: AuthRequirement;
validationProbe: string;
staticModels: string[];
defaultRequestFormat: LlmRequestFormat | null;
supportedRequestFormats: LlmRequestFormat[];
}
/** Core owns protocol, defaults, and credential requirements. */
+3
View File
@@ -7,6 +7,7 @@ import {
type StartupSnapshot,
} from './shared';
import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from './mock-data';
import { invalidateMockChannelTests } from './channels';
export { BACKEND_CONTRACT_VERSION };
export type { StartupSnapshot };
@@ -27,7 +28,9 @@ export function getDefaultStyleSystemPrompts(): Promise<StyleSystemPrompts> {
export function setSettings(prefs: UserPreferences): Promise<void> {
return invokeOrMock('set_settings', { prefs }, () => {
const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled;
mockSetSettings(prefs);
if (thinkingChanged) invalidateMockChannelTests('llm');
return undefined;
});
}
+5 -4
View File
@@ -245,8 +245,7 @@ export interface WindowsImeStatus {
dllPath: string | null;
}
/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查
* latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */
/** 后台自动更新渠道。未明确选择时跟随构建类型;手动检查按钮不受此字段影响。 */
export type UpdateChannel = 'stable' | 'beta';
export type ThemeMode = 'system' | 'light' | 'dark';
@@ -471,9 +470,11 @@ export interface UserPreferences {
startMinimized: boolean;
/** UI theme preference: follow OS, light, or dark. */
themeMode: ThemeMode;
/** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest;
* beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */
/** 后台自动更新渠道。用户未明确选择时跟随当前构建类型;
* About / Advanced 的手动检查按钮各自固定 stable/beta。 */
updateChannel: UpdateChannel;
/** 是否由用户明确选择过更新渠道;缺失时由当前构建类型决定默认渠道。 */
updateChannelExplicit?: boolean;
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
* 到原一次性插入。默认 true。 */
+1
View File
@@ -63,6 +63,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
codingPlanX: 'codingPlanX',
minimax: 'minimax',
stepfun: 'stepfun',
opencode: 'opencode',
custom: 'custom',
};
@@ -18,6 +18,7 @@ import { CheckUpdateButton } from './CheckUpdateButton';
export function BetaChannelSection() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
const [autoCheckChannel, setAutoCheckChannel] = useState<UpdateChannel | null>(null);
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
useEffect(() => {
@@ -45,7 +46,9 @@ export function BetaChannelSection() {
await setUpdateChannel(target);
} catch {
setChannel(target === 'beta' ? 'stable' : 'beta');
return;
}
setAutoCheckChannel(target);
};
if (platformCaps?.supportsAutoUpdate !== true) return null;
@@ -60,7 +63,7 @@ export function BetaChannelSection() {
<Toggle on={channel === 'beta'} onToggle={onToggle} />
</SettingRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<CheckUpdateButton channel="beta" />
<CheckUpdateButton channel="beta" autoCheckChannel={autoCheckChannel} />
</div>
</Card>
);
@@ -38,6 +38,8 @@ const descriptors: ProviderDescriptor[] = [
: 'api_key',
validationProbe: 'unsupported',
staticModels: [],
defaultRequestFormat: null,
supportedRequestFormats: [],
}));
const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) =>
@@ -63,6 +63,8 @@ interface PresetOption {
defaultModel?: string;
authRequirement?: ProviderDescriptor['authRequirement'];
staticModels?: string[];
defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat'];
supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats'];
}
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
@@ -81,6 +83,8 @@ export function presetsFor(
defaultModel: descriptor.defaultModel ?? undefined,
authRequirement: descriptor.authRequirement,
staticModels: descriptor.staticModels,
defaultRequestFormat: descriptor.defaultRequestFormat,
supportedRequestFormats: descriptor.supportedRequestFormats,
}));
if (kind === 'llm') return descriptorPresets;
const available = descriptorPresets;
@@ -905,6 +909,7 @@ function ChannelModal({
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [providerType, setProviderType] = useState(channel.providerType);
const [changingProvider, setChangingProvider] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const nameId = useId();
@@ -1013,15 +1018,18 @@ function ChannelModal({
const changeProvider = async (next: string) => {
const previous = providerType;
onUserMutation();
setProviderType(next);
setChangingProvider(true);
try {
await setChannelProviderType(kind, channel.id, next);
await fillProviderDefaults(next);
setProviderType(next);
await onChanged();
} catch (error) {
console.error('[channels] change provider failed', error);
setProviderType(previous);
emitSaved('failed', t('common.operationFailed'));
} finally {
setChangingProvider(false);
}
};
@@ -1090,6 +1098,7 @@ function ChannelModal({
<ChannelFormRow label={t('settings.channels.providerLabel')}>
<SelectLite
value={providerType}
disabled={changingProvider}
onChange={(next) => void changeProvider(next)}
options={presets.map((p) => ({
value: p.id,
@@ -1115,15 +1124,17 @@ function ChannelModal({
</ChannelFormRow>
{/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */}
<ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
channelId={channel.id}
descriptor={descriptor}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
{!changingProvider && (
<ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
channelId={channel.id}
descriptor={descriptor}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
)}
{isLocalEngine && (
<p className="ol-channel-local-hint">{t('settings.channels.localEngineModelHint')}</p>
)}
@@ -11,9 +11,11 @@ import type { UpdateChannel } from '../../lib/ipc';
export function CheckUpdateButton({
channel,
compact = false,
autoCheckChannel,
}: {
channel: UpdateChannel;
compact?: boolean;
autoCheckChannel?: UpdateChannel | null;
}) {
const { t } = useTranslation();
const updater = useAutoUpdate();
@@ -40,6 +42,12 @@ export function CheckUpdateButton({
: 'settings.about.checkStableUpdateBtn';
const label = checking ? t('settings.about.checkingUpdate') : t(labelKey);
useEffect(() => {
if (autoCheckChannel) void updater.checkForUpdates(autoCheckChannel);
// checkForUpdates changes with updater state; this effect is driven only by a channel switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoCheckChannel]);
return (
<>
<button
@@ -110,6 +118,7 @@ export function CheckUpdateButton({
{isDialogStatus(status) && (
<UpdateDialog
status={status}
currentVersion={updater.currentVersion}
version={updater.version}
progress={updater.progress}
downloaded={updater.downloaded}
@@ -0,0 +1,168 @@
import { protocolValidationError, type ProtocolValues } from './LlmProtocolFields';
import { listProviderDescriptors } from '../../lib/ipc/providers';
import {
createChannel,
deleteChannel,
deleteChannelIfBlank,
listChannels,
recordChannelTest,
setChannelProviderType,
} from '../../lib/ipc/channels';
import { readCredential, setCredential } from '../../lib/ipc/asr-credentials';
import { getSettings, setSettings } from '../../lib/ipc/settings';
import { presetsFor } from './ChannelList';
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const values: ProtocolValues = {
'ark.request_format': '',
'ark.messages_thinking': '',
'ark.max_tokens': '',
'ark.thinking_budget': '',
};
assert(
protocolValidationError(values, 'messages') === null,
'Core-compatible defaults must be valid',
);
assert(
protocolValidationError({ ...values, 'ark.request_format': 'invalid' }, 'messages') ===
'llmRequestFormatInvalid',
'Unknown formats must not silently fall back',
);
assert(
protocolValidationError({ ...values, 'ark.max_tokens': '0' }, 'messages') ===
'llmTokenLimitInvalid',
'Zero output tokens must be rejected',
);
assert(
protocolValidationError(
{ ...values, 'ark.messages_thinking': 'budget', 'ark.max_tokens': '1024' },
'messages',
) === 'llmThinkingBudgetInvalid',
'Fixed thinking must leave room for output',
);
assert(
protocolValidationError(
{
...values,
'ark.messages_thinking': 'budget',
'ark.max_tokens': '4096',
'ark.thinking_budget': '2048',
},
'messages',
) === null,
'Valid fixed budget must be accepted',
);
const descriptors = await listProviderDescriptors('llm');
const presets = presetsFor('llm', 'win', true, undefined, descriptors);
assert(
presets.filter((p) => p.id.startsWith('custom')).length === 3,
'Browser catalog should expose three compatibility presets',
);
const opencode = presets.find((p) => p.id === 'opencode');
assert(
opencode?.defaultRequestFormat === 'chat_completions' &&
opencode.defaultEndpoint === 'https://opencode.ai/zen/v1' &&
opencode.defaultModel === 'deepseek-v4-flash',
'OpenCode browser preset must retain Core defaults',
);
assert(
presets.find((p) => p.id === 'custom_messages')?.defaultRequestFormat === 'messages',
'Picker must retain Core protocol defaults',
);
for (const id of ['opencode', 'custom', 'custom_responses', 'custom_messages']) {
const preset = presets.find((item) => item.id === id);
assert(
preset?.supportedRequestFormats?.length === 3,
`${id} must allow all three compatibility formats`,
);
}
for (const id of ['gemini', 'codex_oauth']) {
const descriptor = descriptors.find((item) => item.providerType === id);
assert(descriptor?.supportedRequestFormats?.length === 0, `${id} retains its native protocol`);
}
const first = await createChannel('llm', 'opencode', 'first');
const second = await createChannel('llm', 'custom', 'second');
await setCredential('ark.request_format', 'messages', first);
await setCredential('ark.api_key', 'fixture-key', first);
await setCredential('ark.endpoint', 'https://opencode.ai/zen/go/v1', first);
await setCredential('ark.model_id', 'deepseek-v4-flash', first);
for (const format of ['chat_completions', 'responses', 'messages']) {
await setCredential('ark.request_format', format, first);
assert(
(await readCredential('ark.request_format', first)) === format,
'OpenCode format must survive reload',
);
assert(
(await readCredential('ark.api_key', first)) === 'fixture-key',
'Format changes preserve the key',
);
assert(
(await readCredential('ark.model_id', first)) === 'deepseek-v4-flash',
'Format changes preserve the model',
);
assert(
(await readCredential('ark.endpoint', first)) === 'https://opencode.ai/zen/go/v1',
'Format changes preserve the endpoint',
);
}
await recordChannelTest('llm', first, true, 1, null);
await setCredential('ark.model_id', 'new-model', first);
assert(
(await readCredential('ark.request_format', first)) === 'messages',
'Changing model must preserve format',
);
assert(
(await readCredential('ark.request_format', second)) === null,
'Formats must be scoped by channel',
);
assert(
(await listChannels('llm')).find((c) => c.id === first)?.lastTest === null,
'Credential mutation invalidates old validation',
);
await setChannelProviderType('llm', first, 'custom_responses');
assert(
(await readCredential('ark.request_format', first)) === null,
'Changing preset resets the format override',
);
assert(
(await readCredential('ark.api_key', first)) === 'fixture-key',
'Changing preset preserves the key',
);
await recordChannelTest('llm', first, true, 1, null);
const settings = await getSettings();
const asrTestAt = (await listChannels('asr'))[0].lastTest?.at;
await setSettings({ ...settings, llmThinkingEnabled: !settings.llmThinkingEnabled });
assert(
(await listChannels('llm')).find((c) => c.id === first)?.lastTest === null,
'Changing thinking invalidates LLM tests',
);
assert(
(await listChannels('asr'))[0].lastTest?.at === asrTestAt,
'Changing thinking preserves ASR tests',
);
await setSettings(settings);
const blank = await createChannel('llm', 'custom', '');
assert(await deleteChannelIfBlank('llm', blank), 'An empty browser draft should be recycled');
assert(
!(await listChannels('llm')).some((channel) => channel.id === blank),
'A recycled browser draft must leave no channel',
);
const configured = await createChannel('llm', 'custom', '');
await setCredential('ark.api_key', 'keep-me', configured);
assert(
!(await deleteChannelIfBlank('llm', configured)),
'A configured browser draft must be preserved',
);
assert(
(await listChannels('llm')).some((channel) => channel.id === configured),
'A configured browser draft must remain visible',
);
await deleteChannel('llm', configured);
await deleteChannel('llm', first);
await deleteChannel('llm', second);
@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectLite } from '../../components/ui/SelectLite';
import { readCredential, setCredential } from '../../lib/ipc';
import type { LlmRequestFormat } from '../../lib/ipc/providers';
import { emitSaved } from '../../lib/savedEvent';
import { Btn } from '../_atoms';
import { SettingRow, inputStyle } from './shared';
const accounts = [
'ark.request_format',
'ark.messages_thinking',
'ark.max_tokens',
'ark.thinking_budget',
] as const;
type Account = (typeof accounts)[number];
export type ProtocolValues = Record<Account, string>;
const emptyValues: ProtocolValues = {
'ark.request_format': '',
'ark.messages_thinking': '',
'ark.max_tokens': '',
'ark.thinking_budget': '',
};
/** 界面即时提示;Core 仍对实际存储和发出的请求做同样的校验。 */
export function protocolValidationError(
values: ProtocolValues,
defaultFormat: LlmRequestFormat,
): string | null {
const format = values['ark.request_format'] || defaultFormat;
if (!['chat_completions', 'responses', 'messages'].includes(format))
return 'llmRequestFormatInvalid';
const mode = values['ark.messages_thinking'] || 'adaptive';
if (!['adaptive', 'budget'].includes(mode)) return 'llmThinkingModeInvalid';
const max = values['ark.max_tokens'] || '8192';
const budget = values['ark.thinking_budget'] || '1024';
if (
![max, budget].every(
(value) => /^\d+$/.test(value) && Number(value) > 0 && Number(value) <= 4294967295,
)
)
return 'llmTokenLimitInvalid';
if (
Number(budget) < 1024 ||
(format === 'messages' && mode === 'budget' && Number(budget) >= Number(max))
)
return 'llmThinkingBudgetInvalid';
return null;
}
export function LlmProtocolFields({
channelId,
defaultFormat,
formats,
onUserMutation,
onBlockedChange,
onSaved,
}: {
channelId: string;
defaultFormat: LlmRequestFormat;
formats: LlmRequestFormat[];
onUserMutation: () => void;
onBlockedChange: (account: string, blocked: boolean) => void;
onSaved?: () => void;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<ProtocolValues>(emptyValues);
const [saved, setSaved] = useState<ProtocolValues>(emptyValues);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<'read' | 'save' | null>(null);
const mounted = useRef(true);
const writing = useRef(false);
const dirty = accounts.some((account) => values[account] !== saved[account]);
const validation = protocolValidationError(values, defaultFormat);
useEffect(() => {
mounted.current = true;
Promise.all(accounts.map((account) => readCredential(account, channelId)))
.then((result) => {
if (!mounted.current) return;
const next = Object.fromEntries(
accounts.map((account, index) => [account, result[index] ?? '']),
) as ProtocolValues;
setValues(next);
setSaved(next);
setLoaded(true);
})
.catch(() => {
if (mounted.current) setError('read');
});
return () => {
mounted.current = false;
};
}, [channelId]);
useEffect(() => {
onBlockedChange(
'protocol',
!loaded || saving || dirty || error !== null || validation !== null,
);
}, [loaded, saving, dirty, error, validation, onBlockedChange]);
const save = async (next: ProtocolValues) => {
if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return;
writing.current = true;
setSaving(true);
setError(null);
try {
for (const account of accounts) {
if (next[account] !== saved[account])
await setCredential(account, next[account], channelId);
}
if (mounted.current) {
setSaved(next);
emitSaved('saved', t('common.saved'));
onSaved?.();
}
} catch {
if (mounted.current) {
setError('save');
emitSaved('failed', t('common.operationFailed'));
}
} finally {
writing.current = false;
if (mounted.current) setSaving(false);
}
};
const change = (account: Account, value: string, immediate = false) => {
onUserMutation();
const next = { ...values, [account]: value };
setValues(next);
if (immediate) void save(next);
};
const format = values['ark.request_format'] || defaultFormat;
const mode = values['ark.messages_thinking'] || 'adaptive';
const disabled = !loaded || saving;
const numberField = (account: Account, label: string, placeholder: string) => (
<SettingRow label={label}>
<input
type="number"
min={account === 'ark.thinking_budget' ? 1024 : 1}
step={1}
aria-label={label}
value={values[account]}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => change(account, event.target.value)}
onBlur={() => {
if (dirty) void save(values);
}}
style={{ ...inputStyle, width: '100%', maxWidth: 420 }}
/>
</SettingRow>
);
return (
<>
<SettingRow label={t('settings.providers.requestFormatLabel')}>
<SelectLite
value={format}
disabled={disabled}
ariaLabel={t('settings.providers.requestFormatLabel')}
options={formats.map((value) => ({
value,
label: {
chat_completions: 'Chat Completions',
responses: 'Responses',
messages: 'Messages',
}[value],
}))}
onChange={(value) => change('ark.request_format', value, true)}
/>
</SettingRow>
{format === 'responses' && (
<p style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
{t('settings.providers.responsesThinkingHint')}
</p>
)}
{format === 'messages' && (
<>
<SettingRow label={t('settings.providers.messagesThinkingLabel')}>
<SelectLite
value={mode}
disabled={disabled}
ariaLabel={t('settings.providers.messagesThinkingLabel')}
options={[
{ value: 'adaptive', label: t('settings.providers.thinkingAdaptive') },
{ value: 'budget', label: t('settings.providers.thinkingBudget') },
]}
onChange={(value) => change('ark.messages_thinking', value, true)}
/>
</SettingRow>
{numberField('ark.max_tokens', t('settings.providers.maxTokensLabel'), '8192')}
{mode === 'budget' &&
numberField('ark.thinking_budget', t('settings.providers.thinkingBudgetLabel'), '1024')}
<p style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
{t('settings.providers.messagesThinkingHint')}
</p>
</>
)}
{(validation || error) && (
<p role="alert" style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{validation
? t(`settings.providers.${validation}`)
: t(error === 'read' ? 'settings.providers.readFailed' : 'common.operationFailed')}
</p>
)}
{dirty && (
<Btn size="sm" disabled={disabled || !!validation} onClick={() => void save(values)}>
{t('settings.providers.saveProtocol')}
</Btn>
)}
</>
);
}
@@ -3,6 +3,9 @@ import { ASR_LABELS } from './shared';
import { presetsFor } from './ChannelList';
const atlascloudPreset = LLM_LABELS.find((p) => p.id === 'atlascloud');
if (LLM_LABELS.find((p) => p.id === 'opencode')?.nameKey !== 'opencode') {
throw new Error('OpenCode LLM label is missing');
}
if (!atlascloudPreset) {
throw new Error('Atlas Cloud LLM preset is missing');
@@ -30,6 +33,8 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [
authRequirement: 'endpoint_model_optional_api_key',
validationProbe: 'asr_silence',
staticModels: [],
defaultRequestFormat: null,
supportedRequestFormats: [],
},
]);
@@ -2,6 +2,7 @@
// 界面负责本地化、字段保存反馈,以及用户主动触发的模型拉取和验证。
import {
useCallback,
useEffect,
useId,
useMemo,
@@ -22,6 +23,7 @@ import {
validateProviderCredentials,
type ProviderDescriptor,
} from '../../lib/ipc';
import { LlmProtocolFields } from './LlmProtocolFields';
import { emitSaved } from '../../lib/savedEvent';
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
@@ -135,7 +137,10 @@ export const LLM_LABELS = [
['codingPlanX', 'codingPlanX'],
['minimax', 'minimax'],
['stepfun', 'stepfun'],
['custom', 'custom'],
['opencode', 'opencode'],
['custom', 'customChatCompletions'],
['custom_responses', 'customResponses'],
['custom_messages', 'customMessages'],
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];
// 火山语音转写在资源 ID 为空时显示的默认值。
@@ -165,7 +170,12 @@ export function ChannelCredentialFields({
descriptor?: Partial<
Pick<
ProviderDescriptor,
'authRequirement' | 'defaultEndpoint' | 'defaultModel' | 'staticModels'
| 'authRequirement'
| 'defaultEndpoint'
| 'defaultModel'
| 'staticModels'
| 'defaultRequestFormat'
| 'supportedRequestFormats'
>
>;
/** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */
@@ -176,6 +186,18 @@ export function ChannelCredentialFields({
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [configRevision, setConfigRevision] = useState(0);
const [blockedFields, setBlockedFields] = useState<Record<string, boolean>>({});
const trackField = useCallback((account: string, blocked: boolean) => {
setBlockedFields((previous) =>
previous[account] === blocked ? previous : { ...previous, [account]: blocked },
);
}, []);
const onLlmMutation = () => {
onUserMutation?.();
setConfigRevision((value) => value + 1);
};
const [asrModelRevision, setAsrModelRevision] = useState(0);
const unifiedBailian = providerType === 'bailian';
const [bailianModel, setBailianModel] = useState('');
@@ -200,10 +222,15 @@ export function ChannelCredentialFields({
const onLlmThinkingToggle = (enabled: boolean) => {
if (!prefs) return;
void updatePrefs((current) => ({ ...current, llmThinkingEnabled: enabled })).catch((error) => {
console.error('[settings] failed to update LLM thinking mode', error);
emitSaved('failed', t('common.operationFailed'));
});
onLlmMutation();
trackField('thinking', true);
void updatePrefs((current) => ({ ...current, llmThinkingEnabled: enabled }))
.then(() => onTested?.())
.catch((error) => {
console.error('[settings] failed to update LLM thinking mode', error);
emitSaved('failed', t('common.operationFailed'));
})
.finally(() => trackField('thinking', false));
};
// Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时,
@@ -218,6 +245,16 @@ export function ChannelCredentialFields({
const codexOAuthSelected = descriptor?.authRequirement === 'o_auth';
return (
<>
{!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && (
<LlmProtocolFields
channelId={channelId}
defaultFormat={descriptor.defaultRequestFormat}
formats={descriptor.supportedRequestFormats}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
onSaved={onTested}
/>
)}
{codexOAuthSelected ? (
<div
style={{
@@ -238,7 +275,8 @@ export function ChannelCredentialFields({
provider={channelId}
mono
mask
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
/>
<CredentialField
key={`${channelId}:endpoint`}
@@ -247,18 +285,21 @@ export function ChannelCredentialFields({
provider={channelId}
placeholder={defaultEndpoint || 'https://your-endpoint/v1'}
defaultValue={defaultEndpoint || undefined}
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
/>
{providerType === 'custom' && (
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
<>
<CredentialField
key={`${channelId}:extra_headers`}
label={t('settings.providers.extraHeadersLabel')}
account="ark.extra_headers"
provider={channelId}
placeholder={t('settings.providers.extraHeadersPlaceholder')}
mono
mask
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
/>
</>
)}
@@ -279,7 +320,8 @@ export function ChannelCredentialFields({
placeholder={defaultModel || 'model-name'}
mono
defaultValue={defaultModel || undefined}
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
trailing={
<LlmThinkingToggle
enabled={prefs?.llmThinkingEnabled ?? false}
@@ -287,17 +329,24 @@ export function ChannelCredentialFields({
/>
}
/>
{providerType === 'custom' && (
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
<CredentialField
key={`${channelId}:temperature`}
label={t('settings.providers.temperatureLabel')}
account="ark.temperature"
provider={channelId}
placeholder={t('settings.providers.temperaturePlaceholder')}
mono
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation}
onBlockedChange={trackField}
/>
)}
<ProviderTools
key={configRevision}
disabled={
Object.values(blockedFields).some(Boolean) ||
(!!descriptor.supportedRequestFormats?.length && blockedFields.protocol === undefined)
}
kind="llm"
modelAccount="ark.model_id"
provider={channelId}
@@ -765,6 +814,7 @@ function ProviderTools({
onTested,
onUserMutation,
showFetchModels = true,
disabled = false,
}: {
kind: 'llm' | 'asr' | 'omni';
modelAccount: string;
@@ -773,8 +823,16 @@ function ProviderTools({
onTested?: () => void;
onUserMutation?: () => void;
showFetchModels?: boolean;
disabled?: boolean;
}) {
const { t } = useTranslation();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
@@ -782,6 +840,7 @@ function ProviderTools({
const [operation, setOperation] = useState<'validate' | 'models'>('validate');
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
if (!mounted.current) return;
setStatus(next);
setMessage(nextMessage);
};
@@ -790,7 +849,7 @@ function ProviderTools({
// 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。
const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => {
// Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。
if (!provider || kind === 'omni') return;
if (!mounted.current || !provider || kind === 'omni') return;
try {
await recordChannelTest(kind, provider, ok, latencyMs, message);
onTested?.();
@@ -800,6 +859,7 @@ function ProviderTools({
};
const validate = async () => {
if (disabled) return;
onUserMutation?.();
setOperation('validate');
setResult('loading', t('settings.providers.validating'));
@@ -833,6 +893,7 @@ function ProviderTools({
};
const loadModels = async () => {
if (disabled) return;
onUserMutation?.();
setOperation('models');
setResult('loading', t('settings.providers.loadingModels'));
@@ -852,6 +913,7 @@ function ProviderTools({
};
const applyModel = async (model: string) => {
if (disabled) return;
onUserMutation?.();
setOperation('models');
setResult('loading', t('common.saving'));
@@ -911,7 +973,7 @@ function ProviderTools({
type="button"
onClick={loadModels}
style={miniBtnStyle}
disabled={status === 'loading'}
disabled={disabled || status === 'loading'}
>
<Icon name="refresh" size={14} />
{status === 'loading' && operation === 'models'
@@ -922,7 +984,7 @@ function ProviderTools({
<SelectLite
value={selectedModel}
onChange={applyModel}
disabled={status === 'loading'}
disabled={disabled || status === 'loading'}
options={models.map((model) => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
@@ -950,7 +1012,7 @@ function ProviderTools({
color: 'var(--ol-blue)',
borderColor: 'var(--ol-blue)',
}}
disabled={status === 'loading'}
disabled={disabled || status === 'loading'}
>
<Icon name="play" size={13} />
{status === 'loading' && operation === 'validate'
@@ -966,6 +1028,17 @@ function ProviderTools({
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const message = error instanceof Error ? error.message : String(error);
for (const code of [
'llmRequestFormatInvalid',
'llmThinkingModeInvalid',
'llmTokenLimitInvalid',
'llmThinkingBudgetInvalid',
'llmResponseIncomplete',
'llmStreamError',
'llmProtocolHeaderConflict',
]) {
if (message.includes(code)) return t(`settings.providers.${code}`);
}
if (message.startsWith('providerHttpStatus:')) {
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
}
@@ -1006,6 +1079,7 @@ type CredentialFieldStatus =
'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
interface CredentialFieldProps {
onBlockedChange?: (account: string, blocked: boolean) => void;
label: string;
account: string;
provider?: string;
@@ -1033,6 +1107,7 @@ function CredentialField({
onValueChange,
onUserMutation,
options,
onBlockedChange,
}: CredentialFieldProps) {
const fieldId = useId();
const { t } = useTranslation();
@@ -1043,9 +1118,22 @@ function CredentialField({
const [status, setStatus] = useState<CredentialFieldStatus>('idle');
// 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。
const [customModelMode, setCustomModelMode] = useState(false);
useEffect(() => {
onBlockedChange?.(
account,
!loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError',
);
}, [account, loaded, dirty, status, onBlockedChange]);
const debounceRef = useRef<number | null>(null);
const statusRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const editRevision = useRef(0);
const saveQueue = useRef<Promise<void>>(Promise.resolve());
const markMutation = () => {
editRevision.current += 1;
onUserMutation?.();
};
useEffect(() => {
let cancelled = false;
@@ -1109,22 +1197,28 @@ function CredentialField({
const save = async (v: string, force = false) => {
if (!loaded || (!dirty && !force)) return;
if (!mountedRef.current) return;
const revision = editRevision.current;
setStatus('saving');
emitSaved('saving', t('common.saving'));
try {
await setCredential(account, v, provider);
if (!mountedRef.current) return;
// 按编辑顺序写入,旧请求完成不能把新值标记为已保存。
const write = saveQueue.current
.catch(() => undefined)
.then(() => setCredential(account, v, provider));
saveQueue.current = write;
await write;
if (!mountedRef.current || revision !== editRevision.current) return;
setDirty(false);
showTemporaryStatus('saved');
} catch (error) {
if (!mountedRef.current) return;
if (!mountedRef.current || revision !== editRevision.current) return;
console.error('[settings] failed to save credential', account, error);
showTemporaryStatus('saveError');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUserMutation?.();
markMutation();
const v = e.target.value;
setValue(v);
onValueChange?.(v);
@@ -1145,7 +1239,7 @@ function CredentialField({
const fillDefault = async () => {
if (!loaded || !defaultValue) return;
onUserMutation?.();
markMutation();
setValue(defaultValue);
onValueChange?.(defaultValue);
setDirty(true);
@@ -1193,7 +1287,7 @@ function CredentialField({
setCustomModelMode(true);
return;
}
onUserMutation?.();
markMutation();
setValue(v);
onValueChange?.(v);
if (!loaded) return;
+3 -4
View File
@@ -111,9 +111,8 @@
--ol-card-border: #e4e4e7;
--ol-card-shadow:
0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset;
/* 玻璃面板背景(半透明,配 backdrop-filter);FloatingShell 主区非不透明分支用,
light/dark 取值不同需保持主题感知。 */
--ol-panel-glass-bg: rgba(255, 255, 255, 0.94);
/* Keep text surfaces opaque; texture is applied above this solid base. */
--ol-panel-glass-bg: #ffffff;
/* (参考 Codex 侧栏):侧栏底色与右侧纯白内容区拉开明确层次,
此前 #fafafa 对白底几乎无区分。 */
@@ -246,7 +245,7 @@
--ol-bg-base: #09090b;
--ol-bg-elevated: #141417;
/* 玻璃面板背景暗色取值(近实色)。 */
--ol-panel-glass-bg: rgba(24, 24, 27, 0.96);
--ol-panel-glass-bg: #18181b;
--ol-ink: #fafafa;
--ol-ink-2: #d4d4d8;