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:
commit
1d29f8a6cd
53 files changed
+3250
-410
No files matched your search
@@ -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.
|
- **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion.
|
||||||
- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS).
|
- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, 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.
|
- **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)).
|
- **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)).
|
- **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).
|
- **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
@@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
|
|||||||
- **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。
|
- **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。
|
||||||
- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。
|
- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、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(实验性)变体。
|
- **本地 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))。
|
- **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。
|
||||||
- **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。
|
- **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。
|
||||||
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
|
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
|
||||||
|
|||||||
@@ -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` 守护并转发启动意图。
|
`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/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。
|
Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。
|
||||||
|
|
||||||
|
|||||||
@@ -3486,7 +3486,40 @@ impl OpenLessBackend {
|
|||||||
key: CredentialKey,
|
key: CredentialKey,
|
||||||
value: SecretValue,
|
value: SecretValue,
|
||||||
) -> Result<CredentialsStatus, BackendError> {
|
) -> 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?;
|
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
|
self.refresh_and_publish_credentials().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3533,13 +3566,51 @@ impl OpenLessBackend {
|
|||||||
id: String,
|
id: String,
|
||||||
provider_type: String,
|
provider_type: String,
|
||||||
) -> Result<(), BackendError> {
|
) -> Result<(), BackendError> {
|
||||||
self.apply_channel_mutation(ChannelMutation::SetProviderType {
|
let provider_type = provider_type.trim().to_string();
|
||||||
kind,
|
if provider_type.trim().is_empty() {
|
||||||
id,
|
return Err(BackendError::new(
|
||||||
provider_type,
|
BackendErrorCode::InvalidArgument,
|
||||||
})
|
"provider type must not be blank",
|
||||||
.await
|
));
|
||||||
.map(|_| ())
|
}
|
||||||
|
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(
|
pub async fn delete_channel_if_blank(
|
||||||
@@ -3621,6 +3692,12 @@ impl OpenLessBackend {
|
|||||||
.map(|_| ())
|
.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> {
|
pub async fn active_provider(&self, slot: ProviderSlot) -> Result<String, BackendError> {
|
||||||
self.deps.credential_store.active_provider(slot).await
|
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]
|
#[tokio::test]
|
||||||
async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() {
|
async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() {
|
||||||
let (backend, _) = backend();
|
let (backend, _) = backend();
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
|
|||||||
"codingPlanX",
|
"codingPlanX",
|
||||||
"minimax",
|
"minimax",
|
||||||
"stepfun",
|
"stepfun",
|
||||||
|
"opencode",
|
||||||
"custom",
|
"custom",
|
||||||
|
"custom_responses",
|
||||||
|
"custom_messages",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"];
|
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));
|
return Ok(CloudPolisherProvider::Gemini(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_url = endpoint
|
let protocol =
|
||||||
.trim()
|
crate::llm_protocol::LlmProtocolConfig::load(credentials, channel_id, provider_type)
|
||||||
.trim_end_matches('/')
|
.await?;
|
||||||
.trim_end_matches("/chat/completions")
|
|
||||||
.trim_end_matches('/')
|
|
||||||
.to_string();
|
|
||||||
let temperature = read_channel_credential(
|
let temperature = read_channel_credential(
|
||||||
credentials,
|
credentials,
|
||||||
CredentialNamespace::Llm,
|
CredentialNamespace::Llm,
|
||||||
@@ -1153,10 +1153,11 @@ async fn build_cloud_polisher_provider(
|
|||||||
let config = crate::polish::OpenAICompatibleConfig::new(
|
let config = crate::polish::OpenAICompatibleConfig::new(
|
||||||
provider_type,
|
provider_type,
|
||||||
"OpenLess LLM",
|
"OpenLess LLM",
|
||||||
base_url,
|
endpoint,
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
)
|
)
|
||||||
|
.with_protocol(protocol)
|
||||||
.with_thinking_enabled(context.polish.llm_thinking_enabled)
|
.with_thinking_enabled(context.polish.llm_thinking_enabled)
|
||||||
.with_temperature(crate::polish::openai_compatible_temperature_for_provider(
|
.with_temperature(crate::polish::openai_compatible_temperature_for_provider(
|
||||||
provider_type,
|
provider_type,
|
||||||
@@ -1970,14 +1971,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn assert_translation_streams_before_completion(
|
async fn assert_translation_streams_for_protocol(
|
||||||
gemini: bool,
|
gemini: bool,
|
||||||
|
format: crate::llm_protocol::LlmRequestFormat,
|
||||||
|
preset: &str,
|
||||||
cancel: bool,
|
cancel: bool,
|
||||||
fallback: bool,
|
fallback: bool,
|
||||||
) {
|
) {
|
||||||
|
use crate::llm_protocol::LlmRequestFormat;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
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 request = Vec::new();
|
||||||
let mut buf = [0_u8; 4096];
|
let mut buf = [0_u8; 4096];
|
||||||
loop {
|
loop {
|
||||||
@@ -2001,6 +2010,25 @@ mod tests {
|
|||||||
assert!(headers.contains(":streamGenerateContent?alt=sse"));
|
assert!(headers.contains(":streamGenerateContent?alt=sse"));
|
||||||
} else {
|
} else {
|
||||||
assert_eq!(body["stream"], true);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2009,32 +2037,88 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
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 (source_sent, source_ready) = tokio::sync::oneshot::channel();
|
||||||
let (advance, next) = tokio::sync::oneshot::channel();
|
let (advance, next) = tokio::sync::oneshot::channel();
|
||||||
let (finish, finishing) = tokio::sync::oneshot::channel();
|
let (finish, finishing) = tokio::sync::oneshot::channel();
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
let (mut socket, _) = listener.accept().await.unwrap();
|
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();
|
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 event = |text: &str| {
|
||||||
let value = if gemini {
|
let value = if gemini {
|
||||||
serde_json::json!({"candidates":[{"content":{"parts":[{"text":text}]}}]})
|
serde_json::json!({"candidates":[{"content":{"parts":[{"text":text}]}}]})
|
||||||
} else {
|
} 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");
|
let data = format!("data: {value}\n\n");
|
||||||
format!("{:X}\r\n{data}\r\n", data.len())
|
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 {
|
if fallback {
|
||||||
socket
|
socket
|
||||||
.write_all(event("malformed combined output").as_bytes())
|
.write_all(event("malformed combined output").as_bytes())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
socket.write_all(terminal.as_bytes()).await.unwrap();
|
||||||
socket.write_all(b"0\r\n\r\n").await.unwrap();
|
socket.write_all(b"0\r\n\r\n").await.unwrap();
|
||||||
socket.shutdown().await.unwrap();
|
socket.shutdown().await.unwrap();
|
||||||
(socket, _) = listener.accept().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();
|
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 {
|
} else {
|
||||||
socket
|
socket
|
||||||
@@ -2059,6 +2143,7 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = socket.write_all(event(" 🌍\n").as_bytes()).await;
|
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;
|
let _ = socket.write_all(b"0\r\n\r\n").await;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2079,16 +2164,24 @@ mod tests {
|
|||||||
"fixture-key",
|
"fixture-key",
|
||||||
)
|
)
|
||||||
.await;
|
.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 polisher = Arc::new(SharedCloudTextPolisher::new(credentials));
|
||||||
let mut context = DictationContext {
|
let mut context = DictationContext {
|
||||||
llm: ProviderInvocation::new(
|
llm: ProviderInvocation::new("translation", if gemini { "gemini" } else { preset }),
|
||||||
"translation",
|
|
||||||
if gemini {
|
|
||||||
"gemini"
|
|
||||||
} else {
|
|
||||||
"openai-compatible"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
..DictationContext::default()
|
..DictationContext::default()
|
||||||
};
|
};
|
||||||
context.llm.model = Some("fixture-model".into());
|
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]
|
#[tokio::test]
|
||||||
async fn openai_translation_streams_target_before_completion() {
|
async fn openai_translation_streams_target_before_completion() {
|
||||||
assert_translation_streams_before_completion(false, false, false).await;
|
assert_translation_streams_before_completion(false, false, false).await;
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ pub struct ChannelTestSummary {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ChannelMutation {
|
pub enum ChannelMutation {
|
||||||
|
InvalidateTest {
|
||||||
|
kind: ChannelKind,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
InvalidateTests {
|
||||||
|
kind: ChannelKind,
|
||||||
|
},
|
||||||
/// Commit a prepared local runtime and its channel in one metadata revision.
|
/// Commit a prepared local runtime and its channel in one metadata revision.
|
||||||
ActivateLocalAsr {
|
ActivateLocalAsr {
|
||||||
id: Option<String>,
|
id: Option<String>,
|
||||||
@@ -519,6 +526,8 @@ impl CredentialMetadata {
|
|||||||
ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr,
|
ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr,
|
||||||
ChannelMutation::Create { kind, .. }
|
ChannelMutation::Create { kind, .. }
|
||||||
| ChannelMutation::SetProviderType { kind, .. }
|
| ChannelMutation::SetProviderType { kind, .. }
|
||||||
|
| ChannelMutation::InvalidateTest { kind, .. }
|
||||||
|
| ChannelMutation::InvalidateTests { kind }
|
||||||
| ChannelMutation::DeleteIfBlank { kind, .. }
|
| ChannelMutation::DeleteIfBlank { kind, .. }
|
||||||
| ChannelMutation::Rename { kind, .. }
|
| ChannelMutation::Rename { kind, .. }
|
||||||
| ChannelMutation::Delete { kind, .. }
|
| ChannelMutation::Delete { kind, .. }
|
||||||
@@ -630,6 +639,16 @@ impl CredentialMetadata {
|
|||||||
channel.last_test = None;
|
channel.last_test = None;
|
||||||
(kind, ChannelMutationResult::Applied)
|
(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 } => {
|
ChannelMutation::DeleteIfBlank { kind, id } => {
|
||||||
let channels = self.channels.entry(kind).or_default();
|
let channels = self.channels.entry(kind).or_default();
|
||||||
let before = channels.len();
|
let before = channels.len();
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ struct LegacyEntry {
|
|||||||
xfyun_api_key: Option<String>,
|
xfyun_api_key: Option<String>,
|
||||||
temperature: Option<f64>,
|
temperature: Option<f64>,
|
||||||
extra_headers: Option<BTreeMap<String, String>>,
|
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 {
|
impl Default for LegacyEntry {
|
||||||
@@ -125,6 +129,10 @@ impl Default for LegacyEntry {
|
|||||||
xfyun_api_key: None,
|
xfyun_api_key: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
extra_headers: 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.advanced_config,
|
||||||
&self.xfyun_app_id,
|
&self.xfyun_app_id,
|
||||||
&self.xfyun_api_key,
|
&self.xfyun_api_key,
|
||||||
|
&self.request_format,
|
||||||
|
&self.messages_thinking,
|
||||||
|
&self.max_tokens,
|
||||||
|
&self.thinking_budget,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.any(|value| value.as_deref().is_some_and(|value| !value.is_empty()))
|
.any(|value| value.as_deref().is_some_and(|value| !value.is_empty()))
|
||||||
@@ -347,6 +359,15 @@ fn decode_entry(
|
|||||||
(endpoint, entry.base_url),
|
(endpoint, entry.base_url),
|
||||||
(model, entry.model),
|
(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 {
|
if namespace == CredentialNamespace::Asr {
|
||||||
fields.extend([
|
fields.extend([
|
||||||
(VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key),
|
(VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key),
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ pub enum ProviderKind {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProviderRequest {
|
pub struct ProviderRequest {
|
||||||
pub kind: ProviderKind,
|
pub kind: ProviderKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub thinking_enabled: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub channel_id: Option<String>,
|
pub channel_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ mod hotkey_interpreter;
|
|||||||
pub mod language_catalog;
|
pub mod language_catalog;
|
||||||
mod less_computer;
|
mod less_computer;
|
||||||
pub mod llm_gemini;
|
pub mod llm_gemini;
|
||||||
|
pub mod llm_protocol;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
pub mod model_store;
|
pub mod model_store;
|
||||||
pub mod net;
|
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))]
|
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
|
||||||
#![allow(clippy::too_many_arguments)]
|
#![allow(clippy::too_many_arguments)]
|
||||||
//! OpenAI-compatible chat completions client + polish prompts.
|
//! 渠道级文本协议客户端与润色提示词。
|
||||||
//!
|
//!
|
||||||
//! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例`
|
//! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例`
|
||||||
//! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。
|
//! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。
|
||||||
@@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, StreamEvent, TextEventStream};
|
||||||
use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage};
|
use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage};
|
||||||
use crate::types::PolishMode;
|
use crate::types::PolishMode;
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@ pub(crate) fn polish_total_timeout_secs(input_chars: usize) -> Duration {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OpenAICompatibleConfig {
|
pub struct OpenAICompatibleConfig {
|
||||||
|
pub protocol: LlmProtocolConfig,
|
||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -120,6 +122,10 @@ impl OpenAICompatibleConfig {
|
|||||||
let temperature = openai_compatible_temperature_for_provider(&provider_id, None);
|
let temperature = openai_compatible_temperature_for_provider(&provider_id, None);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
protocol: LlmProtocolConfig {
|
||||||
|
format: LlmRequestFormat::default_for(&provider_id),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
provider_id,
|
provider_id,
|
||||||
display_name: display_name.into(),
|
display_name: display_name.into(),
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
@@ -137,6 +143,11 @@ impl OpenAICompatibleConfig {
|
|||||||
self
|
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 {
|
pub fn with_extra_headers(mut self, extra_headers: HashMap<String, String>) -> Self {
|
||||||
self.extra_headers = extra_headers;
|
self.extra_headers = extra_headers;
|
||||||
self
|
self
|
||||||
@@ -176,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
|
|||||||
| "codingPlanX"
|
| "codingPlanX"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
| "stepfun"
|
| "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>(
|
pub async fn polish_streaming<F, C>(
|
||||||
&self,
|
&self,
|
||||||
raw_text: &str,
|
raw_text: &str,
|
||||||
@@ -473,7 +491,6 @@ impl ActiveLLMProvider {
|
|||||||
|
|
||||||
pub struct OpenAICompatibleLLMProvider {
|
pub struct OpenAICompatibleLLMProvider {
|
||||||
config: OpenAICompatibleConfig,
|
config: OpenAICompatibleConfig,
|
||||||
client: reqwest::Client,
|
|
||||||
/// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的
|
/// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的
|
||||||
/// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。
|
/// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。
|
||||||
///
|
///
|
||||||
@@ -489,15 +506,8 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
// pool survives across utterances instead of paying a fresh TLS handshake
|
// pool survives across utterances instead of paying a fresh TLS handshake
|
||||||
// every polish. Falls back to a default client if the builder somehow fails
|
// every polish. Falls back to a default client if the builder somehow fails
|
||||||
// so we still surface a useful error at request time.
|
// so we still surface a useful error at request time.
|
||||||
let timeout = config.request_timeout_secs;
|
|
||||||
let no_proxy =
|
let no_proxy =
|
||||||
crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_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_base_url = config.base_url.clone();
|
||||||
let polish_client =
|
let polish_client =
|
||||||
crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || {
|
crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || {
|
||||||
@@ -507,7 +517,6 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
});
|
});
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
client,
|
|
||||||
polish_client,
|
polish_client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,7 +727,7 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
user_prompt: &str,
|
user_prompt: &str,
|
||||||
budget: Duration,
|
budget: Duration,
|
||||||
) -> Result<String, LLMError> {
|
) -> 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 messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt);
|
||||||
let body = self.chat_body(false, messages);
|
let body = self.chat_body(false, messages);
|
||||||
|
|
||||||
@@ -740,7 +749,7 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
user_prompt: &str,
|
user_prompt: &str,
|
||||||
budget: Duration,
|
budget: Duration,
|
||||||
) -> Result<String, LLMError> {
|
) -> 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(
|
let body = self.chat_body(
|
||||||
false,
|
false,
|
||||||
vec![
|
vec![
|
||||||
@@ -760,6 +769,9 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn chat_body(&self, stream: bool, messages: Vec<Value>) -> Value {
|
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!({
|
let mut body = json!({
|
||||||
"model": self.config.model,
|
"model": self.config.model,
|
||||||
"stream": stream,
|
"stream": stream,
|
||||||
@@ -810,17 +822,19 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
url: &str,
|
url: &str,
|
||||||
body: &serde_json::Value,
|
body: &serde_json::Value,
|
||||||
) -> Result<String, LLMError> {
|
) -> Result<String, LLMError> {
|
||||||
let mut request = self
|
self.config
|
||||||
.polish_client
|
.protocol
|
||||||
.post(url)
|
.validate()
|
||||||
.header("Content-Type", "application/json");
|
.and_then(|_| {
|
||||||
if !self.config.api_key.trim().is_empty() {
|
self.config
|
||||||
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
|
.protocol
|
||||||
}
|
.validate_headers(&self.config.extra_headers)
|
||||||
for (k, v) in &self.config.extra_headers {
|
})
|
||||||
request = request.header(k.as_str(), v.as_str());
|
.map_err(|error| LLMError::ParseError(error.message))?;
|
||||||
}
|
let request = self
|
||||||
let request = request.json(body);
|
.authorize(self.polish_client.post(url))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(body);
|
||||||
|
|
||||||
let response = send_with_transient_retry(request).await?;
|
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>(
|
async fn chat_completion_history_streaming<F, C>(
|
||||||
&self,
|
&self,
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
@@ -855,125 +867,27 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
F: Fn(&str) + Send + Sync,
|
F: Fn(&str) + Send + Sync,
|
||||||
C: Fn() -> bool + Send + Sync,
|
C: Fn() -> bool + Send + Sync,
|
||||||
{
|
{
|
||||||
let mut msgs: Vec<Value> = Vec::with_capacity(history.len() + 1);
|
let mut messages = vec![json!({ "role": "system", "content": system_prompt })];
|
||||||
msgs.push(json!({ "role": "system", "content": system_prompt }));
|
for message in history {
|
||||||
for m in history {
|
messages.push(json!({ "role": message.role, "content": message.content }));
|
||||||
msgs.push(json!({ "role": m.role, "content": m.content }));
|
|
||||||
}
|
}
|
||||||
|
let budget = Duration::from_secs(self.config.request_timeout_secs);
|
||||||
let url = chat_completions_url(&self.config.base_url);
|
tokio::time::timeout(
|
||||||
let body = self.chat_body(true, msgs);
|
budget,
|
||||||
|
self.chat_completion_messages_streaming(
|
||||||
log::info!(
|
messages,
|
||||||
"[llm] POST {} provider={} model={} chat_turns={} stream=true",
|
StreamingTimeouts {
|
||||||
crate::net::sanitized_url_for_logs(&url),
|
first_token: budget,
|
||||||
self.config.provider_id,
|
idle: budget,
|
||||||
self.config.model,
|
},
|
||||||
history.len()
|
on_delta,
|
||||||
);
|
should_cancel,
|
||||||
|
),
|
||||||
let mut request = self
|
)
|
||||||
.client
|
.await
|
||||||
.post(&url)
|
.map_err(|_| LLMError::Timeout)?
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把已经构造好的 `messages` 列表(包含 system + 历史 + 当前 user)作为
|
|
||||||
/// `stream: true` 的 body 发出去,SSE 一帧一帧解析。供 `polish_streaming` 复用,
|
|
||||||
/// 跟 `chat_completion_history_streaming` 的 SSE 解析逻辑同款 —— 后者多了一步从
|
|
||||||
/// `QaChatMessage[]` 装配 messages 的工作。
|
|
||||||
async fn chat_completion_messages_streaming<F, C>(
|
async fn chat_completion_messages_streaming<F, C>(
|
||||||
&self,
|
&self,
|
||||||
messages: Vec<Value>,
|
messages: Vec<Value>,
|
||||||
@@ -985,151 +899,132 @@ impl OpenAICompatibleLLMProvider {
|
|||||||
F: Fn(&str) + Send + Sync,
|
F: Fn(&str) + Send + Sync,
|
||||||
C: Fn() -> bool + 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 body = self.chat_body(true, messages);
|
||||||
|
log::info!(
|
||||||
let mut request = self
|
"[llm] POST {} provider={} model={} format={:?} stream=true",
|
||||||
.polish_client
|
crate::net::sanitized_url_for_logs(&url),
|
||||||
.post(&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("Content-Type", "application/json")
|
||||||
.header("Accept", "text/event-stream");
|
.header("Accept", "text/event-stream")
|
||||||
if !self.config.api_key.trim().is_empty() {
|
.json(&body);
|
||||||
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
|
let started = std::time::Instant::now();
|
||||||
}
|
// 取消要能唤醒正在等待网络数据的请求,不能只在 chunk 之间检查。
|
||||||
for (k, v) in &self.config.extra_headers {
|
let cancellation = async {
|
||||||
request = request.header(k.as_str(), v.as_str());
|
while !should_cancel() {
|
||||||
}
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||||
let request = request.json(&body);
|
}
|
||||||
|
};
|
||||||
let response = send_with_transient_retry(request).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();
|
let status = response.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body_text = response.text().await.map_err(llm_error_from_reqwest)?;
|
let body_text = tokio::select! {
|
||||||
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
|
_ = &mut cancellation => return Err(LLMError::Network("cancelled".into())),
|
||||||
let preview = safe_str_slice(&body_text, preview_end);
|
result = tokio::time::timeout(timeouts.first_token.saturating_sub(started.elapsed()), response.text()) => {
|
||||||
log::error!("[llm] streaming HTTP {} body={}", status.as_u16(), preview);
|
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
|
||||||
|
}
|
||||||
|
};
|
||||||
return Err(LLMError::InvalidResponse {
|
return Err(LLMError::InvalidResponse {
|
||||||
status: status.as_u16(),
|
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 events = TextEventStream::new(self.config.protocol.format);
|
||||||
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 full_text = String::new();
|
||||||
let mut delta_count: u64 = 0;
|
|
||||||
let mut cancelled = false;
|
let mut cancelled = false;
|
||||||
let stream_started = std::time::Instant::now();
|
while !events.done {
|
||||||
let mut first_content_at: Option<Duration> = None;
|
|
||||||
loop {
|
|
||||||
if should_cancel() {
|
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;
|
cancelled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 首字之前用「还剩多少首字预算」,首字之后用「两个 chunk 之间能空多久」。
|
let budget = if full_text.is_empty() {
|
||||||
// 注意首字预算是从请求发出起算的**总量**,不随 chunk 到达而重置——推理模型
|
timeouts.first_token.saturating_sub(started.elapsed())
|
||||||
// 思考期的 reasoning_content 是一串正常 chunk,若让它续命,用户干等就没有上限。
|
} else {
|
||||||
let budget = match first_content_at {
|
timeouts.idle
|
||||||
None => timeouts
|
|
||||||
.first_token
|
|
||||||
.saturating_sub(stream_started.elapsed()),
|
|
||||||
Some(_) => timeouts.idle,
|
|
||||||
};
|
};
|
||||||
let chunk_opt = match tokio::time::timeout(budget, response.chunk()).await {
|
let chunk = tokio::select! {
|
||||||
Ok(result) => result.map_err(llm_error_from_reqwest)?,
|
_ = &mut cancellation => { cancelled = true; break; }
|
||||||
Err(_) => {
|
result = tokio::time::timeout(budget, response.chunk()) => {
|
||||||
// 已经交给 on_delta 的字此刻就在用户屏幕上;上层 dictation 的 Failed
|
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
|
||||||
// 分支拿 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 Some(chunk) = chunk_opt else { break };
|
let Some(chunk) = chunk else {
|
||||||
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
|
break;
|
||||||
|
};
|
||||||
while let Some(idx) = buffer.find("\n\n") {
|
events.push(&chunk)?;
|
||||||
let event = buffer[..idx].to_string();
|
loop {
|
||||||
buffer.drain(..idx + 2);
|
if should_cancel() {
|
||||||
for line in event.lines() {
|
cancelled = true;
|
||||||
let Some(payload) = line
|
break;
|
||||||
.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(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 {
|
if !cancelled {
|
||||||
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
|
events.finish()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[llm] polish stream done; total deltas={} chars={}",
|
"[llm] stream done; cancelled={} chars={}",
|
||||||
delta_count,
|
cancelled,
|
||||||
full_text.chars().count()
|
full_text.chars().count()
|
||||||
);
|
);
|
||||||
|
|
||||||
if full_text.is_empty() {
|
if full_text.is_empty() {
|
||||||
return Err(LLMError::InvalidResponse {
|
return Err(LLMError::InvalidResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: "empty polish stream".to_string(),
|
body: "empty polish stream".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(full_text)
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -1905,8 +1800,23 @@ pub(crate) fn apply_openai_compatible_thinking_control(
|
|||||||
) {
|
) {
|
||||||
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
|
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
|
||||||
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
|
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
|
||||||
let control = openai_compatible_thinking_control(provider_id)
|
// Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。
|
||||||
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url));
|
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 {
|
match control {
|
||||||
Some(ThinkingControl::ReasoningEffort) => {
|
Some(ThinkingControl::ReasoningEffort) => {
|
||||||
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
|
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
|
||||||
@@ -2084,7 +1994,7 @@ mod tests {
|
|||||||
"https://user:pass@example.com/v1/chat/completions?token=query-secret#client-fragment"
|
"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::sync::Mutex as StdMutex;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
@@ -2379,6 +2289,256 @@ mod tests {
|
|||||||
request
|
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]]) {
|
fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) {
|
||||||
stream
|
stream
|
||||||
.write_all(
|
.write_all(
|
||||||
@@ -2393,6 +2553,86 @@ mod tests {
|
|||||||
stream.write_all(b"0\r\n\r\n").unwrap();
|
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 前先睡一段,用来模拟「思考很久才出字」和
|
/// 带间隔的 SSE 发送:每个 chunk 前先睡一段,用来模拟「思考很久才出字」和
|
||||||
/// 「出字中途卡死」两种真实流。
|
/// 「出字中途卡死」两种真实流。
|
||||||
fn write_chunked_sse_response_with_delays(
|
fn write_chunked_sse_response_with_delays(
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
|
|||||||
("codingPlanX", "codingPlanX"),
|
("codingPlanX", "codingPlanX"),
|
||||||
("minimax", "minimax"),
|
("minimax", "minimax"),
|
||||||
("stepfun", "stepfun"),
|
("stepfun", "stepfun"),
|
||||||
("custom", "custom"),
|
("opencode", "opencode"),
|
||||||
|
("custom", "customChatCompletions"),
|
||||||
|
("custom_responses", "customResponses"),
|
||||||
|
("custom_messages", "customMessages"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[
|
const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[
|
||||||
@@ -133,6 +136,8 @@ pub struct ProviderDescriptor {
|
|||||||
pub auth_requirement: AuthRequirement,
|
pub auth_requirement: AuthRequirement,
|
||||||
pub validation_probe: ValidationProbe,
|
pub validation_probe: ValidationProbe,
|
||||||
pub static_models: Vec<String>,
|
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> {
|
pub fn provider_descriptors(kind: ProviderKind) -> Vec<ProviderDescriptor> {
|
||||||
@@ -210,6 +215,16 @@ fn provider_descriptor_with_label(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
Some(ProviderDescriptor {
|
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,
|
kind,
|
||||||
provider_type,
|
provider_type,
|
||||||
label_key: label_key.to_string(),
|
label_key: label_key.to_string(),
|
||||||
@@ -422,6 +437,8 @@ pub fn equivalent_endpoint(left: &str, right: &str) -> bool {
|
|||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.trim_end_matches("/chat/completions")
|
.trim_end_matches("/chat/completions")
|
||||||
|
.trim_end_matches("/responses")
|
||||||
|
.trim_end_matches("/messages")
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
}
|
}
|
||||||
normalize(left).eq_ignore_ascii_case(normalize(right))
|
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> {
|
pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
|
||||||
match provider_type {
|
match provider_type {
|
||||||
|
"opencode" => Some("https://opencode.ai/zen/v1"),
|
||||||
"ark" => Some("https://ark.cn-beijing.volces.com/api/v3"),
|
"ark" => Some("https://ark.cn-beijing.volces.com/api/v3"),
|
||||||
"deepseek" => Some("https://api.deepseek.com/v1"),
|
"deepseek" => Some("https://api.deepseek.com/v1"),
|
||||||
"siliconflow" => Some("https://api.siliconflow.cn/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> {
|
pub fn default_llm_model(provider_type: &str) -> Option<&'static str> {
|
||||||
match provider_type {
|
match provider_type {
|
||||||
"ark" => Some("deepseek-v3-2"),
|
"ark" => Some("deepseek-v3-2"),
|
||||||
"deepseek" => Some("deepseek-v4-flash"),
|
"deepseek" | "opencode" => Some("deepseek-v4-flash"),
|
||||||
"siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"),
|
"siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"),
|
||||||
"atlascloud" => Some("qwen/qwen3.5-flash"),
|
"atlascloud" => Some("qwen/qwen3.5-flash"),
|
||||||
"openai" | "cometapi" => Some("gpt-4o"),
|
"openai" | "cometapi" => Some("gpt-4o"),
|
||||||
@@ -956,6 +974,42 @@ mod tests {
|
|||||||
assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err());
|
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]
|
#[test]
|
||||||
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
|
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
|
||||||
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
|
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::domains::{
|
|||||||
ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest,
|
ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest,
|
||||||
};
|
};
|
||||||
use crate::errors::{BackendError, BackendErrorCode};
|
use crate::errors::{BackendError, BackendErrorCode};
|
||||||
|
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat};
|
||||||
use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine};
|
use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine};
|
||||||
use crate::provider_rules::{
|
use crate::provider_rules::{
|
||||||
api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint,
|
api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint,
|
||||||
@@ -158,6 +159,13 @@ impl ProviderService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(ResolvedProvider {
|
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,
|
kind: request.kind,
|
||||||
provider_id,
|
provider_id,
|
||||||
provider_type,
|
provider_type,
|
||||||
@@ -350,6 +358,8 @@ impl ProviderApi for ProviderService {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ResolvedProvider {
|
struct ResolvedProvider {
|
||||||
|
thinking_enabled: bool,
|
||||||
|
protocol: LlmProtocolConfig,
|
||||||
kind: ProviderKind,
|
kind: ProviderKind,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
provider_type: String,
|
provider_type: String,
|
||||||
@@ -362,6 +372,7 @@ struct ResolvedProvider {
|
|||||||
impl ResolvedProvider {
|
impl ResolvedProvider {
|
||||||
fn context(&self) -> DictationContext {
|
fn context(&self) -> DictationContext {
|
||||||
let mut context = DictationContext::default();
|
let mut context = DictationContext::default();
|
||||||
|
context.polish.llm_thinking_enabled = self.thinking_enabled;
|
||||||
let invocation = ProviderInvocation {
|
let invocation = ProviderInvocation {
|
||||||
provider_id: self.provider_id.clone(),
|
provider_id: self.provider_id.clone(),
|
||||||
provider_type: self.provider_type.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)?;
|
validate_provider_endpoint(endpoint, resolved.kind == ProviderKind::Asr)?;
|
||||||
}
|
}
|
||||||
if let Some(headers) = resolved.extra_headers.as_deref() {
|
if let Some(headers) = resolved.extra_headers.as_deref() {
|
||||||
parse_extra_headers(headers)?;
|
resolved
|
||||||
|
.protocol
|
||||||
|
.validate_headers(&parse_extra_headers(headers)?)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -585,6 +598,19 @@ fn sanitize_validation_error(error: BackendError) -> BackendError {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
let message = error.message.as_str();
|
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") {
|
if message.ends_with("is not configured") {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
@@ -620,8 +646,7 @@ async fn fetch_models(
|
|||||||
.or_else(|| default_omni_endpoint(&resolved.provider_type))
|
.or_else(|| default_omni_endpoint(&resolved.provider_type))
|
||||||
.ok_or_else(|| provider_error("provider endpoint is not configured"))?;
|
.ok_or_else(|| provider_error("provider endpoint is not configured"))?;
|
||||||
let url = models_url(endpoint)?;
|
let url = models_url(endpoint)?;
|
||||||
let is_gemini =
|
let is_gemini = resolved.provider_type == "gemini";
|
||||||
crate::net::sanitized_url_for_logs(&url).contains("generativelanguage.googleapis.com");
|
|
||||||
let mut request_headers = Vec::new();
|
let mut request_headers = Vec::new();
|
||||||
if let Some(api_key) = resolved
|
if let Some(api_key) = resolved
|
||||||
.api_key
|
.api_key
|
||||||
@@ -631,9 +656,16 @@ async fn fetch_models(
|
|||||||
if is_gemini {
|
if is_gemini {
|
||||||
request_headers.push(("x-goog-api-key".to_string(), api_key.to_string()));
|
request_headers.push(("x-goog-api-key".to_string(), api_key.to_string()));
|
||||||
} else {
|
} 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() {
|
if let Some(extra_headers) = resolved.extra_headers.as_deref() {
|
||||||
for (name, value) in parse_extra_headers(extra_headers)? {
|
for (name, value) in parse_extra_headers(extra_headers)? {
|
||||||
request_headers.push((name, value));
|
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> {
|
fn models_url(endpoint: &str) -> Result<String, BackendError> {
|
||||||
let mut url = url::Url::parse(endpoint.trim())
|
crate::llm_protocol::endpoint_url(endpoint, "/models")
|
||||||
.map_err(|_| invalid_request("provider endpoint is invalid"))?;
|
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_transport_error(error: ProviderTransportError) -> BackendError {
|
fn map_transport_error(error: ProviderTransportError) -> BackendError {
|
||||||
@@ -866,6 +888,48 @@ mod tests {
|
|||||||
id
|
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]
|
#[tokio::test]
|
||||||
async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() {
|
async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() {
|
||||||
let (endpoint, request) =
|
let (endpoint, request) =
|
||||||
@@ -885,6 +949,7 @@ mod tests {
|
|||||||
|
|
||||||
service
|
service
|
||||||
.validate(ProviderRequest {
|
.validate(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Asr,
|
kind: ProviderKind::Asr,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -918,6 +983,7 @@ mod tests {
|
|||||||
|
|
||||||
service
|
service
|
||||||
.validate(ProviderRequest {
|
.validate(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -951,6 +1017,7 @@ mod tests {
|
|||||||
|
|
||||||
let error = service
|
let error = service
|
||||||
.validate(ProviderRequest {
|
.validate(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -981,6 +1048,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = service
|
let result = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Asr,
|
kind: ProviderKind::Asr,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -1024,6 +1092,7 @@ mod tests {
|
|||||||
|
|
||||||
service
|
service
|
||||||
.validate(ProviderRequest {
|
.validate(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Asr,
|
kind: ProviderKind::Asr,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -1092,6 +1161,7 @@ mod tests {
|
|||||||
let (service, credentials) = service_with_channel().await;
|
let (service, credentials) = service_with_channel().await;
|
||||||
let error = service
|
let error = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some("missing".to_string()),
|
channel_id: Some("missing".to_string()),
|
||||||
})
|
})
|
||||||
@@ -1108,6 +1178,7 @@ mod tests {
|
|||||||
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
|
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
|
||||||
let error = service
|
let error = service
|
||||||
.validate(ProviderRequest {
|
.validate(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Omni,
|
kind: ProviderKind::Omni,
|
||||||
channel_id: Some("channel".to_string()),
|
channel_id: Some("channel".to_string()),
|
||||||
})
|
})
|
||||||
@@ -1166,6 +1237,7 @@ mod tests {
|
|||||||
|
|
||||||
let first_resolved = service
|
let first_resolved = service
|
||||||
.resolve(ProviderRequest {
|
.resolve(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(first_id.clone()),
|
channel_id: Some(first_id.clone()),
|
||||||
})
|
})
|
||||||
@@ -1173,6 +1245,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let second_resolved = service
|
let second_resolved = service
|
||||||
.resolve(ProviderRequest {
|
.resolve(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(second_id.clone()),
|
channel_id: Some(second_id.clone()),
|
||||||
})
|
})
|
||||||
@@ -1180,6 +1253,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let active_resolved = service
|
let active_resolved = service
|
||||||
.resolve(ProviderRequest {
|
.resolve(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: None,
|
channel_id: None,
|
||||||
})
|
})
|
||||||
@@ -1286,6 +1360,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = service
|
let result = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
})
|
})
|
||||||
@@ -1327,6 +1402,7 @@ mod tests {
|
|||||||
transport.push_response(status, br#"{"data":[]}"#);
|
transport.push_response(status, br#"{"data":[]}"#);
|
||||||
let error = service
|
let error = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel.clone()),
|
channel_id: Some(channel.clone()),
|
||||||
})
|
})
|
||||||
@@ -1340,6 +1416,7 @@ mod tests {
|
|||||||
transport.push_response(200, br#"not-json secret-body"#);
|
transport.push_response(200, br#"not-json secret-body"#);
|
||||||
let error = service
|
let error = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel.clone()),
|
channel_id: Some(channel.clone()),
|
||||||
})
|
})
|
||||||
@@ -1351,6 +1428,7 @@ mod tests {
|
|||||||
transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]);
|
transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]);
|
||||||
let error = service
|
let error = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel.clone()),
|
channel_id: Some(channel.clone()),
|
||||||
})
|
})
|
||||||
@@ -1389,6 +1467,7 @@ mod tests {
|
|||||||
transport.push_error(transport_error);
|
transport.push_error(transport_error);
|
||||||
let error = service
|
let error = service
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel.clone()),
|
channel_id: Some(channel.clone()),
|
||||||
})
|
})
|
||||||
@@ -1409,6 +1488,7 @@ mod tests {
|
|||||||
let error = service
|
let error = service
|
||||||
.list_models_with_cancellation(
|
.list_models_with_cancellation(
|
||||||
ProviderRequest {
|
ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
},
|
},
|
||||||
@@ -1441,6 +1521,7 @@ mod tests {
|
|||||||
let error = service
|
let error = service
|
||||||
.list_models_with_cancellation(
|
.list_models_with_cancellation(
|
||||||
ProviderRequest {
|
ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Asr,
|
kind: ProviderKind::Asr,
|
||||||
channel_id: Some(channel),
|
channel_id: Some(channel),
|
||||||
},
|
},
|
||||||
@@ -1490,6 +1571,8 @@ mod tests {
|
|||||||
];
|
];
|
||||||
for (provider_type, expected_models) in expected {
|
for (provider_type, expected_models) in expected {
|
||||||
let resolved = ResolvedProvider {
|
let resolved = ResolvedProvider {
|
||||||
|
thinking_enabled: false,
|
||||||
|
protocol: LlmProtocolConfig::default(),
|
||||||
kind: ProviderKind::Asr,
|
kind: ProviderKind::Asr,
|
||||||
provider_id: provider_type.to_string(),
|
provider_id: provider_type.to_string(),
|
||||||
provider_type: provider_type.to_string(),
|
provider_type: provider_type.to_string(),
|
||||||
|
|||||||
@@ -565,6 +565,10 @@ pub struct UserPreferences {
|
|||||||
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
|
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub update_channel: UpdateChannel,
|
pub update_channel: UpdateChannel,
|
||||||
|
/// 是否由用户明确选择过更新渠道。旧版默认会把 Stable 写入配置,单看
|
||||||
|
/// `update_channel` 无法区分默认值与主动切换;历史 Beta 则必然来自用户 opt-in。
|
||||||
|
#[serde(default)]
|
||||||
|
pub update_channel_explicit: bool,
|
||||||
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。
|
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。
|
||||||
/// 写入新条目时执行清理,避免后台轮询。
|
/// 写入新条目时执行清理,避免后台轮询。
|
||||||
#[serde(default = "default_history_retention_days")]
|
#[serde(default = "default_history_retention_days")]
|
||||||
@@ -892,6 +896,8 @@ struct UserPreferencesWire {
|
|||||||
sherpa_onnx_keep_loaded_secs: u32,
|
sherpa_onnx_keep_loaded_secs: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
update_channel: UpdateChannel,
|
update_channel: UpdateChannel,
|
||||||
|
#[serde(default)]
|
||||||
|
update_channel_explicit: Option<bool>,
|
||||||
#[serde(default = "default_history_retention_days")]
|
#[serde(default = "default_history_retention_days")]
|
||||||
history_retention_days: u32,
|
history_retention_days: u32,
|
||||||
#[serde(default = "default_polish_context_window_minutes")]
|
#[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_language_hint: prefs.sherpa_onnx_language_hint,
|
||||||
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
|
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
|
||||||
update_channel: prefs.update_channel,
|
update_channel: prefs.update_channel,
|
||||||
|
// None 保留旧配置缺少标记的信息;反序列化时只有历史 Beta 视为显式选择。
|
||||||
|
update_channel_explicit: None,
|
||||||
history_retention_days: prefs.history_retention_days,
|
history_retention_days: prefs.history_retention_days,
|
||||||
polish_context_window_minutes: prefs.polish_context_window_minutes,
|
polish_context_window_minutes: prefs.polish_context_window_minutes,
|
||||||
start_minimized: prefs.start_minimized,
|
start_minimized: prefs.start_minimized,
|
||||||
@@ -1129,6 +1137,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
|||||||
};
|
};
|
||||||
let (local_asr_active_model, local_whisper_active_model) =
|
let (local_asr_active_model, local_whisper_active_model) =
|
||||||
migrate_local_asr_models(wire.local_asr_active_model, wire.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 {
|
Ok(Self {
|
||||||
hotkey: wire.hotkey,
|
hotkey: wire.hotkey,
|
||||||
@@ -1224,6 +1235,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
|||||||
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
|
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
|
||||||
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
|
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
|
||||||
update_channel: wire.update_channel,
|
update_channel: wire.update_channel,
|
||||||
|
update_channel_explicit,
|
||||||
history_retention_days: wire.history_retention_days,
|
history_retention_days: wire.history_retention_days,
|
||||||
polish_context_window_minutes: wire.polish_context_window_minutes,
|
polish_context_window_minutes: wire.polish_context_window_minutes,
|
||||||
start_minimized: wire.start_minimized,
|
start_minimized: wire.start_minimized,
|
||||||
@@ -1565,6 +1577,7 @@ impl Default for UserPreferences {
|
|||||||
sherpa_onnx_language_hint: String::new(),
|
sherpa_onnx_language_hint: String::new(),
|
||||||
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||||
update_channel: UpdateChannel::default(),
|
update_channel: UpdateChannel::default(),
|
||||||
|
update_channel_explicit: false,
|
||||||
history_retention_days: default_history_retention_days(),
|
history_retention_days: default_history_retention_days(),
|
||||||
polish_context_window_minutes: default_polish_context_window_minutes(),
|
polish_context_window_minutes: default_polish_context_window_minutes(),
|
||||||
start_minimized: false,
|
start_minimized: false,
|
||||||
@@ -3133,6 +3146,32 @@ mod tests {
|
|||||||
assert!(!prefs.streaming_insert_save_clipboard);
|
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]
|
#[test]
|
||||||
fn paste_shortcut_round_trips_explicit_values() {
|
fn paste_shortcut_round_trips_explicit_values() {
|
||||||
for (raw, expected) in [
|
for (raw, expected) in [
|
||||||
|
|||||||
@@ -1108,6 +1108,7 @@ mod tests {
|
|||||||
.services()
|
.services()
|
||||||
.provider
|
.provider
|
||||||
.list_models(ProviderRequest {
|
.list_models(ProviderRequest {
|
||||||
|
thinking_enabled: false,
|
||||||
kind: ProviderKind::Llm,
|
kind: ProviderKind::Llm,
|
||||||
channel_id: None,
|
channel_id: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ mod linux_app {
|
|||||||
.services()
|
.services()
|
||||||
.provider
|
.provider
|
||||||
.list_models(openless_core::ProviderRequest {
|
.list_models(openless_core::ProviderRequest {
|
||||||
|
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
|
||||||
kind: provider_kind(kind),
|
kind: provider_kind(kind),
|
||||||
channel_id: Some(channel_id.clone()),
|
channel_id: Some(channel_id.clone()),
|
||||||
})
|
})
|
||||||
@@ -2500,6 +2501,7 @@ mod linux_app {
|
|||||||
.services()
|
.services()
|
||||||
.provider
|
.provider
|
||||||
.validate(openless_core::ProviderRequest {
|
.validate(openless_core::ProviderRequest {
|
||||||
|
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
|
||||||
kind: provider_kind(kind),
|
kind: provider_kind(kind),
|
||||||
channel_id: Some(channel_id.clone()),
|
channel_id: Some(channel_id.clone()),
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+1
@@ -4241,6 +4241,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ tauri-plugin-shell = "2.3.5"
|
|||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
semver = "1"
|
||||||
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
|
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
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 {
|
pub fn version_is_newer(remote: &str, current: &str) -> bool {
|
||||||
fn parts(v: &str) -> Vec<u32> {
|
let (Ok(remote), Ok(current)) = (
|
||||||
v.split(|c| c == '.' || c == '-')
|
semver::Version::parse(remote),
|
||||||
.filter_map(|p| p.parse().ok())
|
semver::Version::parse(current),
|
||||||
.collect()
|
) else {
|
||||||
}
|
return false;
|
||||||
let remote_parts = parts(remote);
|
};
|
||||||
let current_parts = parts(current);
|
remote > 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stable_manifest_urls(arch: &str) -> Vec<String> {
|
pub fn stable_manifest_urls(arch: &str) -> Vec<String> {
|
||||||
@@ -78,9 +66,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_is_newer_handles_beta_suffix() {
|
fn version_is_newer_uses_semver_prerelease_ordering() {
|
||||||
assert!(version_is_newer("1.3.8-1", "1.3.8"));
|
assert!(version_is_newer("1.3.18", "1.3.18-Beta.7"));
|
||||||
assert!(!version_is_newer("1.3.8", "1.3.8-1"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ fn read_vault_credential(
|
|||||||
key: &openless_core::CredentialKey,
|
key: &openless_core::CredentialKey,
|
||||||
) -> Result<Option<String>, openless_core::BackendError> {
|
) -> Result<Option<String>, openless_core::BackendError> {
|
||||||
let result = match (key.namespace, key.account.as_str()) {
|
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) => {
|
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
|
||||||
match key.provider_id.as_deref() {
|
match key.provider_id.as_deref() {
|
||||||
Some(provider) => serde_json::to_string(
|
Some(provider) => serde_json::to_string(
|
||||||
@@ -316,6 +321,11 @@ fn write_vault_credential(
|
|||||||
value: &str,
|
value: &str,
|
||||||
) -> Result<(), openless_core::BackendError> {
|
) -> Result<(), openless_core::BackendError> {
|
||||||
let result = match (key.namespace, key.account.as_str()) {
|
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) => {
|
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
|
||||||
match key.provider_id.as_deref() {
|
match key.provider_id.as_deref() {
|
||||||
Some(provider) => {
|
Some(provider) => {
|
||||||
@@ -745,6 +755,9 @@ fn credential_key(
|
|||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
) -> Result<openless_core::CredentialKey, String> {
|
) -> Result<openless_core::CredentialKey, String> {
|
||||||
let namespace = match account {
|
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 => {
|
LLM_EXTRA_HEADERS_ACCOUNT | LLM_TEMPERATURE_ACCOUNT => {
|
||||||
openless_core::CredentialNamespace::Llm
|
openless_core::CredentialNamespace::Llm
|
||||||
}
|
}
|
||||||
@@ -860,6 +873,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn core_llm_accounts_are_supported_by_the_tauri_vault_adapter() {
|
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 [
|
for account in [
|
||||||
openless_core::credentials::LLM_API_KEY_ACCOUNT,
|
openless_core::credentials::LLM_API_KEY_ACCOUNT,
|
||||||
openless_core::credentials::LLM_MODEL_ACCOUNT,
|
openless_core::credentials::LLM_MODEL_ACCOUNT,
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ pub async fn validate_provider_credentials(
|
|||||||
let kind = parse_provider_kind(&kind)?;
|
let kind = parse_provider_kind(&kind)?;
|
||||||
core.services()
|
core.services()
|
||||||
.provider
|
.provider
|
||||||
.validate(openless_core::ProviderRequest { kind, channel_id })
|
.validate(openless_core::ProviderRequest {
|
||||||
|
kind,
|
||||||
|
channel_id,
|
||||||
|
thinking_enabled: core.get_preferences().llm_thinking_enabled,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.message)
|
.map_err(|error| error.message)
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,11 @@ pub async fn list_provider_models(
|
|||||||
let kind = parse_provider_kind(&kind)?;
|
let kind = parse_provider_kind(&kind)?;
|
||||||
core.services()
|
core.services()
|
||||||
.provider
|
.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
|
.await
|
||||||
.map_err(|error| error.message)
|
.map_err(|error| error.message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use super::*;
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_settings(core: CoreState<'_>) -> UserPreferences {
|
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]
|
#[tauri::command]
|
||||||
@@ -143,8 +145,10 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> Result<(), String> {
|
fn persist_settings_with_host_lock_held(
|
||||||
let _host_guard = coord.lock_settings_host();
|
coord: &Coordinator,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
) -> Result<(), String> {
|
||||||
coord
|
coord
|
||||||
.backend()
|
.backend()
|
||||||
.update_settings(
|
.update_settings(
|
||||||
@@ -163,11 +167,22 @@ pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> R
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn persist_strict_settings(
|
fn persist_settings_preserving_update_channel(
|
||||||
coord: &Coordinator,
|
coord: &Coordinator,
|
||||||
prefs: UserPreferences,
|
mut prefs: UserPreferences,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let _host_guard = coord.lock_settings_host();
|
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
|
coord
|
||||||
.backend()
|
.backend()
|
||||||
.update_settings(
|
.update_settings(
|
||||||
@@ -179,6 +194,21 @@ pub(crate) fn persist_strict_settings(
|
|||||||
.map_err(|error| error.to_string())
|
.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))]
|
#[cfg(not(mobile))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_settings(
|
pub async fn set_settings(
|
||||||
@@ -194,10 +224,11 @@ pub async fn set_settings(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
sync_style_pack_preferences(&mut prefs, &packs);
|
sync_style_pack_preferences(&mut prefs, &packs);
|
||||||
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
|
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,
|
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
|
||||||
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
||||||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||||||
persist_settings(&*coord, prefs)?;
|
persist_settings_preserving_update_channel(&*coord, prefs)?;
|
||||||
let prefs = coord.backend().get_preferences();
|
let prefs = coord.backend().get_preferences();
|
||||||
// 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule
|
// 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule
|
||||||
// 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
|
// 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
|
||||||
@@ -245,7 +276,10 @@ pub async fn set_settings(
|
|||||||
|
|
||||||
#[cfg(mobile)]
|
#[cfg(mobile)]
|
||||||
#[tauri::command]
|
#[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 previous = coord.backend().get_preferences();
|
||||||
let packs = coord
|
let packs = coord
|
||||||
.backend()
|
.backend()
|
||||||
@@ -253,7 +287,8 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) ->
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
sync_style_pack_preferences(&mut prefs, &packs);
|
sync_style_pack_preferences(&mut prefs, &packs);
|
||||||
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
|
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();
|
let prefs = coord.backend().get_preferences();
|
||||||
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
|
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
|
||||||
coord.sync_capsule_style_from_preferences();
|
coord.sync_capsule_style_from_preferences();
|
||||||
@@ -293,6 +328,63 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(stale_settings_payload.default_mode, PolishMode::Light);
|
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, ¤t);
|
||||||
|
|
||||||
|
assert_eq!(stale_payload.update_channel, UpdateChannel::Stable);
|
||||||
|
assert!(stale_payload.update_channel_explicit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
||||||
@@ -308,9 +400,38 @@ mod tests {
|
|||||||
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
|
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
|
||||||
// Release assets 里物理分离)。
|
// 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]
|
#[tauri::command]
|
||||||
pub fn get_update_channel(core: CoreState<'_>) -> UpdateChannel {
|
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]
|
#[tauri::command]
|
||||||
@@ -318,12 +439,13 @@ pub fn set_update_channel(
|
|||||||
coord: CoordinatorState<'_>,
|
coord: CoordinatorState<'_>,
|
||||||
channel: UpdateChannel,
|
channel: UpdateChannel,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// 渠道读取和持久化必须同属一个写临界区,避免反向覆盖并发常规设置。
|
||||||
|
let _host_guard = coord.lock_settings_host();
|
||||||
let mut prefs = coord.backend().get_preferences();
|
let mut prefs = coord.backend().get_preferences();
|
||||||
if prefs.update_channel == channel {
|
if !select_update_channel(&mut prefs, channel) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
prefs.update_channel = channel;
|
persist_settings_with_host_lock_held(&*coord, prefs)?;
|
||||||
persist_settings(&*coord, prefs)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +583,7 @@ pub struct AppUpdateMetadata {
|
|||||||
|
|
||||||
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
|
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
|
||||||
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
|
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
|
||||||
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
|
/// 不传则使用用户明确选择的渠道;尚未选择时跟随当前构建类型。
|
||||||
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
|
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
@@ -473,7 +595,8 @@ pub async fn app_check_update_with_channel<R: tauri::Runtime>(
|
|||||||
) -> Result<Option<AppUpdateMetadata>, String> {
|
) -> Result<Option<AppUpdateMetadata>, String> {
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
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();
|
let mut builder = webview.updater_builder();
|
||||||
if let Some(ms) = timeout_ms {
|
if let Some(ms) = timeout_ms {
|
||||||
builder = builder.timeout(std::time::Duration::from_millis(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> {
|
) -> Result<Option<AppUpdateMetadata>, String> {
|
||||||
#[cfg(target_os = "android")]
|
#[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;
|
return crate::android::updater::check_update(channel).await;
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|||||||
@@ -383,9 +383,28 @@ struct CredsLlmEntry {
|
|||||||
temperature: Option<f64>,
|
temperature: Option<f64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
extraHeaders: Option<HashMap<String, String>>,
|
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 {
|
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 {
|
fn is_empty(&self) -> bool {
|
||||||
// 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。
|
// 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。
|
||||||
if self.channel.providerType.is_some() {
|
if self.channel.providerType.is_some() {
|
||||||
@@ -401,6 +420,10 @@ impl CredsLlmEntry {
|
|||||||
&& self.baseURL.as_deref().unwrap_or("").is_empty()
|
&& self.baseURL.as_deref().unwrap_or("").is_empty()
|
||||||
&& self.model.as_deref().unwrap_or("").is_empty()
|
&& self.model.as_deref().unwrap_or("").is_empty()
|
||||||
&& self.temperature.is_none()
|
&& self.temperature.is_none()
|
||||||
|
&& self.requestFormat.is_none()
|
||||||
|
&& self.messagesThinking.is_none()
|
||||||
|
&& self.maxTokens.is_none()
|
||||||
|
&& self.thinkingBudget.is_none()
|
||||||
&& self
|
&& self
|
||||||
.extraHeaders
|
.extraHeaders
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2386,6 +2409,29 @@ impl CredentialsVault {
|
|||||||
save_credentials(&root)
|
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 渠道。
|
/// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。
|
||||||
pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> {
|
pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> {
|
||||||
let _guard = credentials_lock().lock();
|
let _guard = credentials_lock().lock();
|
||||||
@@ -2695,6 +2741,48 @@ mod tests {
|
|||||||
.all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS));
|
.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]
|
#[test]
|
||||||
fn omni_accounts_route_to_omni_namespace_only() {
|
fn omni_accounts_route_to_omni_namespace_only() {
|
||||||
// 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902):
|
// 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902):
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
logClientError,
|
logClientError,
|
||||||
openExternal,
|
openExternal,
|
||||||
restartApp,
|
restartApp,
|
||||||
|
setUpdateChannel,
|
||||||
type AppUpdateMetadata,
|
type AppUpdateMetadata,
|
||||||
type UpdateChannel,
|
type UpdateChannel,
|
||||||
} from '../lib/ipc';
|
} from '../lib/ipc';
|
||||||
|
import { isStableChannelSwitch } from '../lib/appVersion';
|
||||||
import { Btn } from '../pages/_atoms';
|
import { Btn } from '../pages/_atoms';
|
||||||
|
|
||||||
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
||||||
@@ -50,6 +52,7 @@ export type CheckUpdateOptions = {
|
|||||||
|
|
||||||
export interface UseAutoUpdate {
|
export interface UseAutoUpdate {
|
||||||
status: UpdateStatus;
|
status: UpdateStatus;
|
||||||
|
currentVersion: string;
|
||||||
version: string;
|
version: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
@@ -72,6 +75,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
const updateRef = useRef<Update | null>(null);
|
const updateRef = useRef<Update | null>(null);
|
||||||
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
|
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
|
||||||
const [status, setStatus] = useState<UpdateStatus>('idle');
|
const [status, setStatus] = useState<UpdateStatus>('idle');
|
||||||
|
const [currentVersion, setCurrentVersion] = useState('');
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [downloaded, setDownloaded] = useState(0);
|
const [downloaded, setDownloaded] = useState(0);
|
||||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||||
@@ -143,6 +147,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
|
|
||||||
const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => {
|
const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => {
|
||||||
setStatus('checking');
|
setStatus('checking');
|
||||||
|
setCurrentVersion('');
|
||||||
setVersion('');
|
setVersion('');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
resetProgress();
|
resetProgress();
|
||||||
@@ -157,6 +162,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
setStatus('none');
|
setStatus('none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setCurrentVersion(metadata.currentVersion);
|
||||||
if (isAndroid()) {
|
if (isAndroid()) {
|
||||||
storeAndroidMetadata(metadata);
|
storeAndroidMetadata(metadata);
|
||||||
setVersion(metadata.version);
|
setVersion(metadata.version);
|
||||||
@@ -205,12 +211,18 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const installUpdate = async () => {
|
const installUpdate = async () => {
|
||||||
|
const persistStableChannelSwitch = () =>
|
||||||
|
isStableChannelSwitch(currentVersion, version)
|
||||||
|
? setUpdateChannel('stable')
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
if (isAndroid()) {
|
if (isAndroid()) {
|
||||||
const payload = androidUpdateRef.current;
|
const payload = androidUpdateRef.current;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
resetProgress();
|
resetProgress();
|
||||||
setStatus('downloading');
|
setStatus('downloading');
|
||||||
try {
|
try {
|
||||||
|
await persistStableChannelSwitch();
|
||||||
await appDownloadAndInstallAndroidUpdate(payload);
|
await appDownloadAndInstallAndroidUpdate(payload);
|
||||||
androidUpdateRef.current = null;
|
androidUpdateRef.current = null;
|
||||||
setStatus('downloaded');
|
setStatus('downloaded');
|
||||||
@@ -229,6 +241,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
resetProgress();
|
resetProgress();
|
||||||
setStatus('downloading');
|
setStatus('downloading');
|
||||||
try {
|
try {
|
||||||
|
await persistStableChannelSwitch();
|
||||||
await update.download((event: DownloadEvent) => {
|
await update.download((event: DownloadEvent) => {
|
||||||
if (event.event === 'Started') {
|
if (event.event === 'Started') {
|
||||||
resetProgress();
|
resetProgress();
|
||||||
@@ -257,12 +270,14 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
await closeUpdate();
|
await closeUpdate();
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
|
setCurrentVersion('');
|
||||||
setVersion('');
|
setVersion('');
|
||||||
resetProgress();
|
resetProgress();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
currentVersion,
|
||||||
version,
|
version,
|
||||||
progress,
|
progress,
|
||||||
downloaded,
|
downloaded,
|
||||||
@@ -290,6 +305,7 @@ export function isDialogStatus(
|
|||||||
|
|
||||||
export function UpdateDialog({
|
export function UpdateDialog({
|
||||||
status,
|
status,
|
||||||
|
currentVersion,
|
||||||
version,
|
version,
|
||||||
progress,
|
progress,
|
||||||
downloaded,
|
downloaded,
|
||||||
@@ -299,6 +315,7 @@ export function UpdateDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError';
|
status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError';
|
||||||
|
currentVersion: string;
|
||||||
version: string;
|
version: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
@@ -312,6 +329,8 @@ export function UpdateDialog({
|
|||||||
const installing = status === 'installing';
|
const installing = status === 'installing';
|
||||||
const installError = status === 'installError';
|
const installError = status === 'installError';
|
||||||
const androidInstalled = isAndroid() && status === 'downloaded';
|
const androidInstalled = isAndroid() && status === 'downloaded';
|
||||||
|
const switchingToStable =
|
||||||
|
status === 'available' && isStableChannelSwitch(currentVersion, version);
|
||||||
// Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change,
|
// Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change,
|
||||||
// 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗
|
// 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗
|
||||||
// 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。
|
// 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。
|
||||||
@@ -339,7 +358,9 @@ export function UpdateDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>
|
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>
|
||||||
{t(`settings.about.updateDialog.${status}.title`)}
|
{t(
|
||||||
|
`settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -361,7 +382,12 @@ export function UpdateDialog({
|
|||||||
? t('settings.about.updateDialog.installError.desc', {
|
? t('settings.about.updateDialog.installError.desc', {
|
||||||
error: errorMessage || t('settings.about.updateError'),
|
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>
|
</div>
|
||||||
{(downloading || installing || status === 'downloaded') && (
|
{(downloading || installing || status === 'downloaded') && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function AutoUpdateGate() {
|
|||||||
return (
|
return (
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
status={u.status}
|
status={u.status}
|
||||||
|
currentVersion={u.currentVersion}
|
||||||
version={u.version}
|
version={u.version}
|
||||||
progress={u.progress}
|
progress={u.progress}
|
||||||
downloaded={u.downloaded}
|
downloaded={u.downloaded}
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ import { useAppState, type AppTab } from '../state/useAppState';
|
|||||||
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections'];
|
const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections'];
|
||||||
const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace'];
|
const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace'];
|
||||||
|
|
||||||
/** macOS 上侧栏顶部需让开原生红绿灯的高度。红绿灯在 (20,20),按钮组高约 14px,
|
/** Reserve the native traffic-light strip before the sidebar's version row. */
|
||||||
* 上下留等距 20px → 避让带总高 54px,红绿灯恰好竖直居中、左缘与导航图标列对齐。 */
|
|
||||||
const MAC_TRAFFIC_LIGHT_CLEARANCE = 44;
|
const MAC_TRAFFIC_LIGHT_CLEARANCE = 44;
|
||||||
const SIDEBAR_WIDTH = 226;
|
const SIDEBAR_WIDTH = 226;
|
||||||
|
|
||||||
|
|||||||
@@ -1274,6 +1274,10 @@ export const de: typeof zhCN = {
|
|||||||
pipelineIsolationNotice:
|
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.',
|
'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: {
|
presets: {
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: 'Benutzerdefiniert · Chat Completions',
|
||||||
|
customResponses: 'Benutzerdefiniert · Responses',
|
||||||
|
customMessages: 'Benutzerdefiniert · Messages',
|
||||||
ark: 'ARK (Volcengine Ark)',
|
ark: 'ARK (Volcengine Ark)',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
siliconflow: 'SiliconFlow',
|
siliconflow: 'SiliconFlow',
|
||||||
@@ -1377,8 +1381,30 @@ export const de: typeof zhCN = {
|
|||||||
thinkingModeLabel: 'Denkmodus',
|
thinkingModeLabel: 'Denkmodus',
|
||||||
thinkingModeOn: 'Ein',
|
thinkingModeOn: 'Ein',
|
||||||
thinkingModeOff: 'Aus',
|
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:
|
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)',
|
bailianVocabularyIdLabel: 'Wörterbuch-ID für Begriffe (optional)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'Wenn du bei DashScope ein Begriffswörterbuch erstellt hast, gib seine vocab-... ID ein. Leer lassen, um keine Begriffe zu übergeben.',
|
'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',
|
'Neueste Beta prüfen und herunterladen, anschließend den Updatedialog anzeigen',
|
||||||
betaChannelChecking: 'Wird geprüft…',
|
betaChannelChecking: 'Wird geprüft…',
|
||||||
updateDialog: {
|
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: {
|
available: {
|
||||||
title: 'Update verfügbar',
|
title: 'Update verfügbar',
|
||||||
desc: 'OpenLess {{version}} ist verfügbar. Jetzt aktualisieren?',
|
desc: 'OpenLess {{version}} ist verfügbar. Jetzt aktualisieren?',
|
||||||
|
|||||||
@@ -1256,6 +1256,10 @@ export const en: typeof zhCN = {
|
|||||||
codingPlanX: 'CodingPlanX',
|
codingPlanX: 'CodingPlanX',
|
||||||
minimax: 'MiniMax (M3)',
|
minimax: 'MiniMax (M3)',
|
||||||
stepfun: 'StepFun',
|
stepfun: 'StepFun',
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: 'Custom · Chat Completions',
|
||||||
|
customResponses: 'Custom · Responses',
|
||||||
|
customMessages: 'Custom · Messages',
|
||||||
custom: 'Custom',
|
custom: 'Custom',
|
||||||
asrVolcengine: 'Volcengine bigasr',
|
asrVolcengine: 'Volcengine bigasr',
|
||||||
asrBailian: 'Alibaba Bailian realtime ASR',
|
asrBailian: 'Alibaba Bailian realtime ASR',
|
||||||
@@ -1343,8 +1347,29 @@ export const en: typeof zhCN = {
|
|||||||
thinkingModeLabel: 'Thinking',
|
thinkingModeLabel: 'Thinking',
|
||||||
thinkingModeOn: 'On',
|
thinkingModeOn: 'On',
|
||||||
thinkingModeOff: 'Off',
|
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:
|
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)',
|
bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.',
|
'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',
|
title: 'Update available',
|
||||||
desc: 'OpenLess {{version}} is available. Update now?',
|
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: {
|
downloading: {
|
||||||
title: 'Downloading update',
|
title: 'Downloading update',
|
||||||
desc: 'Downloading OpenLess {{version}}. Keep the app open.',
|
desc: 'Downloading OpenLess {{version}}. Keep the app open.',
|
||||||
|
|||||||
@@ -1266,6 +1266,10 @@ export const es: typeof zhCN = {
|
|||||||
pipelineIsolationNotice:
|
pipelineIsolationNotice:
|
||||||
'Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.',
|
'Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.',
|
||||||
presets: {
|
presets: {
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: 'Personalizado · Chat Completions',
|
||||||
|
customResponses: 'Personalizado · Responses',
|
||||||
|
customMessages: 'Personalizado · Messages',
|
||||||
ark: 'ARK (Volcengine Ark)',
|
ark: 'ARK (Volcengine Ark)',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
siliconflow: 'SiliconFlow',
|
siliconflow: 'SiliconFlow',
|
||||||
@@ -1368,8 +1372,30 @@ export const es: typeof zhCN = {
|
|||||||
thinkingModeLabel: 'Razonamiento',
|
thinkingModeLabel: 'Razonamiento',
|
||||||
thinkingModeOn: 'Activado',
|
thinkingModeOn: 'Activado',
|
||||||
thinkingModeOff: 'Desactivado',
|
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:
|
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)',
|
bailianVocabularyIdLabel: 'ID del vocabulario de palabras clave (opcional)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'Si creaste un vocabulario en DashScope, introduce su ID vocab-... Déjalo vacío para no usar palabras clave.',
|
'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',
|
'Busca y descarga la última Beta y muestra el diálogo de actualización',
|
||||||
betaChannelChecking: 'Buscando…',
|
betaChannelChecking: 'Buscando…',
|
||||||
updateDialog: {
|
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: {
|
available: {
|
||||||
title: 'Actualización disponible',
|
title: 'Actualización disponible',
|
||||||
desc: 'OpenLess {{version}} está disponible. ¿Actualizar ahora?',
|
desc: 'OpenLess {{version}} está disponible. ¿Actualizar ahora?',
|
||||||
|
|||||||
@@ -1283,6 +1283,10 @@ export const fr: typeof zhCN = {
|
|||||||
pipelineIsolationNotice:
|
pipelineIsolationNotice:
|
||||||
'Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.',
|
'Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.',
|
||||||
presets: {
|
presets: {
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: 'Personnalisé · Chat Completions',
|
||||||
|
customResponses: 'Personnalisé · Responses',
|
||||||
|
customMessages: 'Personnalisé · Messages',
|
||||||
ark: 'ARK (Volcengine Ark)',
|
ark: 'ARK (Volcengine Ark)',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
siliconflow: 'SiliconFlow',
|
siliconflow: 'SiliconFlow',
|
||||||
@@ -1386,8 +1390,32 @@ export const fr: typeof zhCN = {
|
|||||||
thinkingModeLabel: 'Raisonnement',
|
thinkingModeLabel: 'Raisonnement',
|
||||||
thinkingModeOn: 'Activé',
|
thinkingModeOn: 'Activé',
|
||||||
thinkingModeOff: 'Désactivé',
|
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:
|
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)',
|
bailianVocabularyIdLabel: 'ID du vocabulaire de mots-clés (facultatif)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'Si vous avez créé un vocabulaire dans DashScope, saisissez son ID vocab-... Laissez vide pour ne pas utiliser de mots-clés.',
|
'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',
|
'Recherche et télécharge la dernière Beta, puis affiche le dialogue de mise à jour',
|
||||||
betaChannelChecking: 'Recherche…',
|
betaChannelChecking: 'Recherche…',
|
||||||
updateDialog: {
|
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: {
|
available: {
|
||||||
title: 'Mise à jour disponible',
|
title: 'Mise à jour disponible',
|
||||||
desc: 'OpenLess {{version}} est disponible. Mettre à jour maintenant ?',
|
desc: 'OpenLess {{version}} est disponible. Mettre à jour maintenant ?',
|
||||||
|
|||||||
@@ -1253,6 +1253,10 @@ export const ja: typeof zhCN = {
|
|||||||
codingPlanX: 'CodingPlanX',
|
codingPlanX: 'CodingPlanX',
|
||||||
minimax: 'MiniMax(M3)',
|
minimax: 'MiniMax(M3)',
|
||||||
stepfun: 'StepFun(階躍星辰)',
|
stepfun: 'StepFun(階躍星辰)',
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: 'カスタム · Chat Completions',
|
||||||
|
customResponses: 'カスタム · Responses',
|
||||||
|
customMessages: 'カスタム · Messages',
|
||||||
custom: 'カスタム',
|
custom: 'カスタム',
|
||||||
asrVolcengine: 'Volcengine bigasr',
|
asrVolcengine: 'Volcengine bigasr',
|
||||||
asrBailian: 'Alibaba Bailian リアルタイム ASR',
|
asrBailian: 'Alibaba Bailian リアルタイム ASR',
|
||||||
@@ -1340,8 +1344,28 @@ export const ja: typeof zhCN = {
|
|||||||
thinkingModeLabel: '思考',
|
thinkingModeLabel: '思考',
|
||||||
thinkingModeOn: 'オン',
|
thinkingModeOn: 'オン',
|
||||||
thinkingModeOff: 'オフ',
|
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:
|
thinkingModeHint:
|
||||||
'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。',
|
'選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。',
|
||||||
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
|
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
|
'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
|
||||||
@@ -1733,6 +1757,10 @@ export const ja: typeof zhCN = {
|
|||||||
title: '新しいバージョンがあります',
|
title: '新しいバージョンがあります',
|
||||||
desc: 'OpenLess {{version}} が見つかりました。今すぐ更新しますか?',
|
desc: 'OpenLess {{version}} が見つかりました。今すぐ更新しますか?',
|
||||||
},
|
},
|
||||||
|
stableChannelSwitch: {
|
||||||
|
title: '正式版に切り替える',
|
||||||
|
desc: '現在のバージョン:OpenLess {{currentVersion}}\n対象バージョン:OpenLess {{version}}\nBeta チャンネルから正式版に切り替えます。続行しますか?',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
title: 'アップデートをダウンロード中',
|
title: 'アップデートをダウンロード中',
|
||||||
desc: 'OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。',
|
desc: 'OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。',
|
||||||
|
|||||||
@@ -1245,6 +1245,10 @@ export const ko: typeof zhCN = {
|
|||||||
codingPlanX: 'CodingPlanX',
|
codingPlanX: 'CodingPlanX',
|
||||||
minimax: 'MiniMax (M3)',
|
minimax: 'MiniMax (M3)',
|
||||||
stepfun: 'StepFun',
|
stepfun: 'StepFun',
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: '사용자 지정 · Chat Completions',
|
||||||
|
customResponses: '사용자 지정 · Responses',
|
||||||
|
customMessages: '사용자 지정 · Messages',
|
||||||
custom: '사용자 정의',
|
custom: '사용자 정의',
|
||||||
asrVolcengine: 'Volcengine bigasr',
|
asrVolcengine: 'Volcengine bigasr',
|
||||||
asrBailian: 'Alibaba Bailian 실시간 ASR',
|
asrBailian: 'Alibaba Bailian 실시간 ASR',
|
||||||
@@ -1331,8 +1335,29 @@ export const ko: typeof zhCN = {
|
|||||||
thinkingModeLabel: '사고',
|
thinkingModeLabel: '사고',
|
||||||
thinkingModeOn: '켜짐',
|
thinkingModeOn: '켜짐',
|
||||||
thinkingModeOff: '꺼짐',
|
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:
|
thinkingModeHint:
|
||||||
'꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.',
|
'선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.',
|
||||||
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
|
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
|
||||||
bailianVocabularyIdNote:
|
bailianVocabularyIdNote:
|
||||||
'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
|
'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
|
||||||
@@ -1719,6 +1744,10 @@ export const ko: typeof zhCN = {
|
|||||||
title: '새 버전 발견',
|
title: '새 버전 발견',
|
||||||
desc: 'OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?',
|
desc: 'OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?',
|
||||||
},
|
},
|
||||||
|
stableChannelSwitch: {
|
||||||
|
title: '정식 버전으로 전환',
|
||||||
|
desc: '현재 버전: OpenLess {{currentVersion}}\n대상 버전: OpenLess {{version}}\nBeta 채널에서 정식 버전으로 전환합니다. 계속하시겠습니까?',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
title: '업데이트 다운로드 중',
|
title: '업데이트 다운로드 중',
|
||||||
desc: 'OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.',
|
desc: 'OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.',
|
||||||
|
|||||||
@@ -1197,6 +1197,10 @@ export const zhCN = {
|
|||||||
codingPlanX: 'CodingPlanX',
|
codingPlanX: 'CodingPlanX',
|
||||||
minimax: 'MiniMax(M3)',
|
minimax: 'MiniMax(M3)',
|
||||||
stepfun: 'StepFun(阶跃星辰)',
|
stepfun: 'StepFun(阶跃星辰)',
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: '自定义 · Chat Completions',
|
||||||
|
customResponses: '自定义 · Responses',
|
||||||
|
customMessages: '自定义 · Messages',
|
||||||
custom: '自定义',
|
custom: '自定义',
|
||||||
asrVolcengine: '火山引擎 bigasr',
|
asrVolcengine: '火山引擎 bigasr',
|
||||||
asrBailian: '阿里云百炼实时 ASR',
|
asrBailian: '阿里云百炼实时 ASR',
|
||||||
@@ -1280,8 +1284,26 @@ export const zhCN = {
|
|||||||
thinkingModeLabel: '思考',
|
thinkingModeLabel: '思考',
|
||||||
thinkingModeOn: '开启',
|
thinkingModeOn: '开启',
|
||||||
thinkingModeOff: '关闭',
|
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:
|
thinkingModeHint:
|
||||||
'关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。',
|
'按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。',
|
||||||
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
|
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
|
||||||
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
|
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
|
||||||
bailianModelRealtimeHint: '实时模型 · 边说边出字。',
|
bailianModelRealtimeHint: '实时模型 · 边说边出字。',
|
||||||
@@ -1656,6 +1678,10 @@ export const zhCN = {
|
|||||||
title: '发现新版本',
|
title: '发现新版本',
|
||||||
desc: '发现 OpenLess {{version}},是否现在更新?',
|
desc: '发现 OpenLess {{version}},是否现在更新?',
|
||||||
},
|
},
|
||||||
|
stableChannelSwitch: {
|
||||||
|
title: '切换到正式版',
|
||||||
|
desc: '当前版本:OpenLess {{currentVersion}}\n目标版本:OpenLess {{version}}\n这是从 Beta 渠道切换到正式版,是否继续?',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
title: '正在下载更新',
|
title: '正在下载更新',
|
||||||
desc: '正在下载 OpenLess {{version}},请保持应用打开。',
|
desc: '正在下载 OpenLess {{version}},请保持应用打开。',
|
||||||
|
|||||||
@@ -1199,6 +1199,10 @@ export const zhTW: typeof zhCN = {
|
|||||||
codingPlanX: 'CodingPlanX',
|
codingPlanX: 'CodingPlanX',
|
||||||
minimax: 'MiniMax(M3)',
|
minimax: 'MiniMax(M3)',
|
||||||
stepfun: 'StepFun(階躍星辰)',
|
stepfun: 'StepFun(階躍星辰)',
|
||||||
|
opencode: 'OpenCode Zen',
|
||||||
|
customChatCompletions: '自訂 · Chat Completions',
|
||||||
|
customResponses: '自訂 · Responses',
|
||||||
|
customMessages: '自訂 · Messages',
|
||||||
custom: '自定義',
|
custom: '自定義',
|
||||||
asrVolcengine: '火山引擎 bigasr',
|
asrVolcengine: '火山引擎 bigasr',
|
||||||
asrBailian: '阿里雲百煉即時 ASR',
|
asrBailian: '阿里雲百煉即時 ASR',
|
||||||
@@ -1282,8 +1286,26 @@ export const zhTW: typeof zhCN = {
|
|||||||
thinkingModeLabel: '思考',
|
thinkingModeLabel: '思考',
|
||||||
thinkingModeOn: '開啟',
|
thinkingModeOn: '開啟',
|
||||||
thinkingModeOff: '關閉',
|
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:
|
thinkingModeHint:
|
||||||
'關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。',
|
'依所選請求格式與模型支援的參數啟用、關閉或降低思考,不在提示詞注入控制指令。',
|
||||||
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
|
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
|
||||||
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
|
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
|
||||||
bailianModelRealtimeHint: '即時模型 · 邊說邊出字。',
|
bailianModelRealtimeHint: '即時模型 · 邊說邊出字。',
|
||||||
@@ -1642,6 +1664,10 @@ export const zhTW: typeof zhCN = {
|
|||||||
title: '發現新版本',
|
title: '發現新版本',
|
||||||
desc: '發現 OpenLess {{version}},是否現在更新?',
|
desc: '發現 OpenLess {{version}},是否現在更新?',
|
||||||
},
|
},
|
||||||
|
stableChannelSwitch: {
|
||||||
|
title: '切換到正式版',
|
||||||
|
desc: '目前版本:OpenLess {{currentVersion}}\n目標版本:OpenLess {{version}}\n這是從 Beta 頻道切換到正式版,是否繼續?',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
title: '正在下載更新',
|
title: '正在下載更新',
|
||||||
desc: '正在下載 OpenLess {{version}},請保持應用打開。',
|
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');
|
||||||
@@ -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`。
|
// 例如 `1.2.24-1` / `1.2.24-2`;正式版没有 `-`,例如 `1.2.23` / `1.2.24`。
|
||||||
// 用于 UI 条件渲染——Beta 标签只在 Beta build 出现。
|
// 用于 UI 条件渲染——Beta 标签只在 Beta build 出现。
|
||||||
export const IS_BETA_BUILD = APP_VERSION.includes('-');
|
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 type { CredentialsStatus } from '../types';
|
||||||
import { invokeOrMock } from './shared';
|
import { invokeOrMock } from './shared';
|
||||||
import { mockCredentialsStatus } from './mock-data';
|
import { mockCredentialsStatus, mockCredentialValues } from './mock-data';
|
||||||
|
import { invalidateMockChannelTest } from './channels';
|
||||||
|
|
||||||
export interface ProviderCheckResult {
|
export interface ProviderCheckResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -15,7 +16,10 @@ export function getCredentials(): Promise<CredentialsStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
|
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> {
|
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> {
|
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。 */
|
/** `channelId` 省略时测当前生效的渠道;卡片上的「测试连通」会带上那张卡片的 id。 */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Core 选择排序后的第一个启用渠道;凭据通过 readCredential/setCredential 按渠道 ID 读写。
|
// Core 选择排序后的第一个启用渠道;凭据通过 readCredential/setCredential 按渠道 ID 读写。
|
||||||
|
|
||||||
import { invokeOrMock } from './shared';
|
import { invokeOrMock } from './shared';
|
||||||
|
import { mockCredentialValues } from './mock-data';
|
||||||
|
|
||||||
export type ChannelKind = 'llm' | 'asr';
|
export type ChannelKind = 'llm' | 'asr';
|
||||||
|
|
||||||
@@ -82,13 +83,33 @@ export function listChannels(kind: ChannelKind): Promise<Channel[]> {
|
|||||||
return invokeOrMock('list_channels', { kind }, () => mockChannels[kind]);
|
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。 */
|
/** 返回后端分配的渠道 id。 */
|
||||||
export function createChannel(
|
export function createChannel(
|
||||||
kind: ChannelKind,
|
kind: ChannelKind,
|
||||||
providerType: string,
|
providerType: string,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<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,
|
id: string,
|
||||||
providerType: string,
|
providerType: string,
|
||||||
): Promise<void> {
|
): 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> {
|
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> {
|
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> {
|
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> {
|
export function setChannelEnabled(kind: ChannelKind, id: string, enabled: boolean): Promise<void> {
|
||||||
@@ -142,5 +186,8 @@ export function recordChannelTest(
|
|||||||
latencyMs: number | null,
|
latencyMs: number | null,
|
||||||
error: string | null,
|
error: string | null,
|
||||||
): Promise<void> {
|
): 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 {
|
import type {
|
||||||
ActivityDay,
|
ActivityDay,
|
||||||
CorrectionRule,
|
CorrectionRule,
|
||||||
@@ -109,6 +110,7 @@ export let mockSettings: UserPreferences = {
|
|||||||
startMinimized: false,
|
startMinimized: false,
|
||||||
themeMode: 'system',
|
themeMode: 'system',
|
||||||
updateChannel: 'stable',
|
updateChannel: 'stable',
|
||||||
|
updateChannelExplicit: false,
|
||||||
streamingInsert: true,
|
streamingInsert: true,
|
||||||
streamingInsertDefaultMigrated: true,
|
streamingInsertDefaultMigrated: true,
|
||||||
streamingInsertSaveClipboard: true,
|
streamingInsertSaveClipboard: true,
|
||||||
|
|||||||
@@ -5,28 +5,33 @@
|
|||||||
"authRequirement": "volcengine",
|
"authRequirement": "volcengine",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrVolcengine",
|
"labelKey": "asrVolcengine",
|
||||||
"providerType": "volcengine",
|
"providerType": "volcengine",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence_allows_no_final"
|
"validationProbe": "asr_silence_allows_no_final"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.elevenlabs.io/v1",
|
"defaultEndpoint": "https://api.elevenlabs.io/v1",
|
||||||
"defaultModel": "scribe_v2",
|
"defaultModel": "scribe_v2",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrElevenLabs",
|
"labelKey": "asrElevenLabs",
|
||||||
"providerType": "elevenlabs",
|
"providerType": "elevenlabs",
|
||||||
"staticModels": [
|
"staticModels": [
|
||||||
"scribe_v2"
|
"scribe_v2"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
|
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
|
||||||
"defaultModel": "fun-asr-realtime",
|
"defaultModel": "fun-asr-realtime",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrBailian",
|
"labelKey": "asrBailian",
|
||||||
"providerType": "bailian",
|
"providerType": "bailian",
|
||||||
@@ -46,12 +51,14 @@
|
|||||||
"fun-asr-mtl-2025-08-25",
|
"fun-asr-mtl-2025-08-25",
|
||||||
"paraformer-v2"
|
"paraformer-v2"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
|
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
|
||||||
"defaultModel": "qwen3-asr-flash-realtime",
|
"defaultModel": "qwen3-asr-flash-realtime",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrBailianQwen3",
|
"labelKey": "asrBailianQwen3",
|
||||||
"providerType": "bailian-qwen3-realtime",
|
"providerType": "bailian-qwen3-realtime",
|
||||||
@@ -60,12 +67,14 @@
|
|||||||
"qwen3-asr-flash-realtime-2026-02-10",
|
"qwen3-asr-flash-realtime-2026-02-10",
|
||||||
"qwen3-asr-flash-realtime-2025-10-27"
|
"qwen3-asr-flash-realtime-2025-10-27"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
"defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
||||||
"defaultModel": "fun-asr-flash-2026-06-15",
|
"defaultModel": "fun-asr-flash-2026-06-15",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrBailianFunAsrFlash",
|
"labelKey": "asrBailianFunAsrFlash",
|
||||||
"providerType": "bailian-fun-asr-flash",
|
"providerType": "bailian-fun-asr-flash",
|
||||||
@@ -73,178 +82,213 @@
|
|||||||
"qwen-audio-3.0-asr-flash",
|
"qwen-audio-3.0-asr-flash",
|
||||||
"fun-asr-flash-2026-06-15"
|
"fun-asr-flash-2026-06-15"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_non_silent"
|
"validationProbe": "asr_non_silent"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.siliconflow.cn/v1",
|
"defaultEndpoint": "https://api.siliconflow.cn/v1",
|
||||||
"defaultModel": "FunAudioLLM/SenseVoiceSmall",
|
"defaultModel": "FunAudioLLM/SenseVoiceSmall",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrSiliconflow",
|
"labelKey": "asrSiliconflow",
|
||||||
"providerType": "siliconflow",
|
"providerType": "siliconflow",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.stepfun.com/v1",
|
"defaultEndpoint": "https://api.stepfun.com/v1",
|
||||||
"defaultModel": "stepaudio-2.5-asr",
|
"defaultModel": "stepaudio-2.5-asr",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrStepfun",
|
"labelKey": "asrStepfun",
|
||||||
"providerType": "stepfun",
|
"providerType": "stepfun",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "stepfun_no_speech"
|
"validationProbe": "stepfun_no_speech"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4",
|
"defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"defaultModel": "glm-asr-2512",
|
"defaultModel": "glm-asr-2512",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrZhipu",
|
"labelKey": "asrZhipu",
|
||||||
"providerType": "zhipu",
|
"providerType": "zhipu",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.groq.com/openai/v1",
|
"defaultEndpoint": "https://api.groq.com/openai/v1",
|
||||||
"defaultModel": "whisper-large-v3-turbo",
|
"defaultModel": "whisper-large-v3-turbo",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrGroq",
|
"labelKey": "asrGroq",
|
||||||
"providerType": "groq",
|
"providerType": "groq",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.openai.com/v1",
|
"defaultEndpoint": "https://api.openai.com/v1",
|
||||||
"defaultModel": "whisper-1",
|
"defaultModel": "whisper-1",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrWhisper",
|
"labelKey": "asrWhisper",
|
||||||
"providerType": "whisper",
|
"providerType": "whisper",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://openrouter.ai/api/v1",
|
"defaultEndpoint": "https://openrouter.ai/api/v1",
|
||||||
"defaultModel": "openai/whisper-large-v3-turbo",
|
"defaultModel": "openai/whisper-large-v3-turbo",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrOpenrouter",
|
"labelKey": "asrOpenrouter",
|
||||||
"providerType": "openrouter",
|
"providerType": "openrouter",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://zenmux.ai/api/v1",
|
"defaultEndpoint": "https://zenmux.ai/api/v1",
|
||||||
"defaultModel": "qwen/qwen3-asr-flash",
|
"defaultModel": "qwen/qwen3-asr-flash",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrZenmux",
|
"labelKey": "asrZenmux",
|
||||||
"providerType": "zenmux",
|
"providerType": "zenmux",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "endpoint_model_optional_api_key",
|
"authRequirement": "endpoint_model_optional_api_key",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrOpenAiCompatible",
|
"labelKey": "asrOpenAiCompatible",
|
||||||
"providerType": "openai-compatible",
|
"providerType": "openai-compatible",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
|
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
|
||||||
"defaultModel": "mimo-v2.5-asr",
|
"defaultModel": "mimo-v2.5-asr",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrXiaomiMimo",
|
"labelKey": "asrXiaomiMimo",
|
||||||
"providerType": "xiaomi-mimo-asr",
|
"providerType": "xiaomi-mimo-asr",
|
||||||
"staticModels": [
|
"staticModels": [
|
||||||
"mimo-v2.5-asr"
|
"mimo-v2.5-asr"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence"
|
"validationProbe": "asr_silence"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "xfyun",
|
"authRequirement": "xfyun",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrIflytek",
|
"labelKey": "asrIflytek",
|
||||||
"providerType": "iflytek",
|
"providerType": "iflytek",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "asr_silence_allows_no_final"
|
"validationProbe": "asr_silence_allows_no_final"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrFoundryLocalWhisper",
|
"labelKey": "asrFoundryLocalWhisper",
|
||||||
"providerType": "foundry-local-whisper",
|
"providerType": "foundry-local-whisper",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrLocalWhisper",
|
"labelKey": "asrLocalWhisper",
|
||||||
"providerType": "local-whisper",
|
"providerType": "local-whisper",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrSherpaOnnxLocal",
|
"labelKey": "asrSherpaOnnxLocal",
|
||||||
"providerType": "sherpa-onnx-local",
|
"providerType": "sherpa-onnx-local",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrLocalQwen3Mlx",
|
"labelKey": "asrLocalQwen3Mlx",
|
||||||
"providerType": "local-qwen3-mlx",
|
"providerType": "local-qwen3-mlx",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrLocalQwen3C",
|
"labelKey": "asrLocalQwen3C",
|
||||||
"providerType": "local-qwen3-c",
|
"providerType": "local-qwen3-c",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrLocalQwen3",
|
"labelKey": "asrLocalQwen3",
|
||||||
"providerType": "local-qwen3",
|
"providerType": "local-qwen3",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "none",
|
"authRequirement": "none",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "asr",
|
"kind": "asr",
|
||||||
"labelKey": "asrAppleSpeech",
|
"labelKey": "asrAppleSpeech",
|
||||||
"providerType": "apple-speech",
|
"providerType": "apple-speech",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "unsupported"
|
"validationProbe": "unsupported"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -253,66 +297,99 @@
|
|||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3",
|
"defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
"defaultModel": "deepseek-v3-2",
|
"defaultModel": "deepseek-v3-2",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "ark",
|
"labelKey": "ark",
|
||||||
"providerType": "ark",
|
"providerType": "ark",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.deepseek.com/v1",
|
"defaultEndpoint": "https://api.deepseek.com/v1",
|
||||||
"defaultModel": "deepseek-v4-flash",
|
"defaultModel": "deepseek-v4-flash",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "deepseek",
|
"labelKey": "deepseek",
|
||||||
"providerType": "deepseek",
|
"providerType": "deepseek",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.siliconflow.cn/v1",
|
"defaultEndpoint": "https://api.siliconflow.cn/v1",
|
||||||
"defaultModel": "Qwen/Qwen2.5-7B-Instruct",
|
"defaultModel": "Qwen/Qwen2.5-7B-Instruct",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "siliconflow",
|
"labelKey": "siliconflow",
|
||||||
"providerType": "siliconflow",
|
"providerType": "siliconflow",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.atlascloud.ai/v1",
|
"defaultEndpoint": "https://api.atlascloud.ai/v1",
|
||||||
"defaultModel": "qwen/qwen3.5-flash",
|
"defaultModel": "qwen/qwen3.5-flash",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "atlascloud",
|
"labelKey": "atlascloud",
|
||||||
"providerType": "atlascloud",
|
"providerType": "atlascloud",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.openai.com/v1",
|
"defaultEndpoint": "https://api.openai.com/v1",
|
||||||
"defaultModel": "gpt-4o",
|
"defaultModel": "gpt-4o",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "openai",
|
"labelKey": "openai",
|
||||||
"providerType": "openai",
|
"providerType": "openai",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
|
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
"defaultModel": "gemini-2.5-flash",
|
"defaultModel": "gemini-2.5-flash",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "gemini",
|
"labelKey": "gemini",
|
||||||
"providerType": "gemini",
|
"providerType": "gemini",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "o_auth",
|
"authRequirement": "o_auth",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": "gpt-5.5",
|
"defaultModel": "gpt-5.5",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "codexOAuth",
|
"labelKey": "codexOAuth",
|
||||||
"providerType": "codex_oauth",
|
"providerType": "codex_oauth",
|
||||||
@@ -322,86 +399,183 @@
|
|||||||
"gpt-5.4",
|
"gpt-5.4",
|
||||||
"gpt-5.5"
|
"gpt-5.5"
|
||||||
],
|
],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
|
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
|
||||||
"defaultModel": "xiaomi/mimo-v2-flash",
|
"defaultModel": "xiaomi/mimo-v2-flash",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "mimo",
|
"labelKey": "mimo",
|
||||||
"providerType": "mimo",
|
"providerType": "mimo",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.cometapi.com/v1",
|
"defaultEndpoint": "https://api.cometapi.com/v1",
|
||||||
"defaultModel": "gpt-4o",
|
"defaultModel": "gpt-4o",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "cometapi",
|
"labelKey": "cometapi",
|
||||||
"providerType": "cometapi",
|
"providerType": "cometapi",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://openrouter.ai/api/v1",
|
"defaultEndpoint": "https://openrouter.ai/api/v1",
|
||||||
"defaultModel": "qwen/qwen3-coder:free",
|
"defaultModel": "qwen/qwen3-coder:free",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "openrouterFree",
|
"labelKey": "openrouterFree",
|
||||||
"providerType": "openrouterFree",
|
"providerType": "openrouterFree",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1",
|
"defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||||
"defaultModel": "qwen3-coder-plus",
|
"defaultModel": "qwen3-coder-plus",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "alibabaCoding",
|
"labelKey": "alibabaCoding",
|
||||||
"providerType": "alibabaCoding",
|
"providerType": "alibabaCoding",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.codingplanx.ai/v1",
|
"defaultEndpoint": "https://api.codingplanx.ai/v1",
|
||||||
"defaultModel": "gpt-5-mini",
|
"defaultModel": "gpt-5-mini",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "codingPlanX",
|
"labelKey": "codingPlanX",
|
||||||
"providerType": "codingPlanX",
|
"providerType": "codingPlanX",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.minimaxi.com/v1",
|
"defaultEndpoint": "https://api.minimaxi.com/v1",
|
||||||
"defaultModel": "MiniMax-M3",
|
"defaultModel": "MiniMax-M3",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "minimax",
|
"labelKey": "minimax",
|
||||||
"providerType": "minimax",
|
"providerType": "minimax",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [
|
||||||
|
"chat_completions",
|
||||||
|
"responses",
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"validationProbe": "llm_text"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": "https://api.stepfun.com/v1",
|
"defaultEndpoint": "https://api.stepfun.com/v1",
|
||||||
"defaultModel": "step-1o-turbo-vision",
|
"defaultModel": "step-1o-turbo-vision",
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "stepfun",
|
"labelKey": "stepfun",
|
||||||
"providerType": "stepfun",
|
"providerType": "stepfun",
|
||||||
"staticModels": [],
|
"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"
|
"validationProbe": "llm_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key_unless_custom_endpoint",
|
"authRequirement": "api_key_unless_custom_endpoint",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": "chat_completions",
|
||||||
"kind": "llm",
|
"kind": "llm",
|
||||||
"labelKey": "custom",
|
"labelKey": "customChatCompletions",
|
||||||
"providerType": "custom",
|
"providerType": "custom",
|
||||||
"staticModels": [],
|
"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"
|
"validationProbe": "llm_text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -410,40 +584,48 @@
|
|||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://api.openai.com/v1",
|
"defaultEndpoint": "https://api.openai.com/v1",
|
||||||
"defaultModel": "gpt-4o-audio-preview",
|
"defaultModel": "gpt-4o-audio-preview",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "omni",
|
"kind": "omni",
|
||||||
"labelKey": "omniOpenai",
|
"labelKey": "omniOpenai",
|
||||||
"providerType": "openai",
|
"providerType": "openai",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "omni_text"
|
"validationProbe": "omni_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
|
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
"defaultModel": "gemini-2.5-flash",
|
"defaultModel": "gemini-2.5-flash",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "omni",
|
"kind": "omni",
|
||||||
"labelKey": "omniGemini",
|
"labelKey": "omniGemini",
|
||||||
"providerType": "gemini",
|
"providerType": "gemini",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "omni_text"
|
"validationProbe": "omni_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"defaultModel": "qwen3-omni-flash",
|
"defaultModel": "qwen3-omni-flash",
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "omni",
|
"kind": "omni",
|
||||||
"labelKey": "omniDashscope",
|
"labelKey": "omniDashscope",
|
||||||
"providerType": "dashscope-omni",
|
"providerType": "dashscope-omni",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "omni_text"
|
"validationProbe": "omni_text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"authRequirement": "api_key",
|
"authRequirement": "api_key",
|
||||||
"defaultEndpoint": null,
|
"defaultEndpoint": null,
|
||||||
"defaultModel": null,
|
"defaultModel": null,
|
||||||
|
"defaultRequestFormat": null,
|
||||||
"kind": "omni",
|
"kind": "omni",
|
||||||
"labelKey": "custom",
|
"labelKey": "custom",
|
||||||
"providerType": "custom",
|
"providerType": "custom",
|
||||||
"staticModels": [],
|
"staticModels": [],
|
||||||
|
"supportedRequestFormats": [],
|
||||||
"validationProbe": "omni_text"
|
"validationProbe": "omni_text"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invokeOrMock, isTauri } from './shared';
|
import { invokeOrMock, isTauri } from './shared';
|
||||||
|
|
||||||
export type ProviderKind = 'asr' | 'llm' | 'omni';
|
export type ProviderKind = 'asr' | 'llm' | 'omni';
|
||||||
|
export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages';
|
||||||
|
|
||||||
export type AuthRequirement =
|
export type AuthRequirement =
|
||||||
| 'none'
|
| 'none'
|
||||||
@@ -20,6 +21,8 @@ export interface ProviderDescriptor {
|
|||||||
authRequirement: AuthRequirement;
|
authRequirement: AuthRequirement;
|
||||||
validationProbe: string;
|
validationProbe: string;
|
||||||
staticModels: string[];
|
staticModels: string[];
|
||||||
|
defaultRequestFormat: LlmRequestFormat | null;
|
||||||
|
supportedRequestFormats: LlmRequestFormat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Core owns protocol, defaults, and credential requirements. */
|
/** Core owns protocol, defaults, and credential requirements. */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type StartupSnapshot,
|
type StartupSnapshot,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from './mock-data';
|
import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from './mock-data';
|
||||||
|
import { invalidateMockChannelTests } from './channels';
|
||||||
|
|
||||||
export { BACKEND_CONTRACT_VERSION };
|
export { BACKEND_CONTRACT_VERSION };
|
||||||
export type { StartupSnapshot };
|
export type { StartupSnapshot };
|
||||||
@@ -27,7 +28,9 @@ export function getDefaultStyleSystemPrompts(): Promise<StyleSystemPrompts> {
|
|||||||
|
|
||||||
export function setSettings(prefs: UserPreferences): Promise<void> {
|
export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||||
return invokeOrMock('set_settings', { prefs }, () => {
|
return invokeOrMock('set_settings', { prefs }, () => {
|
||||||
|
const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled;
|
||||||
mockSetSettings(prefs);
|
mockSetSettings(prefs);
|
||||||
|
if (thinkingChanged) invalidateMockChannelTests('llm');
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -245,8 +245,7 @@ export interface WindowsImeStatus {
|
|||||||
dllPath: string | null;
|
dllPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查
|
/** 后台自动更新渠道。未明确选择时跟随构建类型;手动检查按钮不受此字段影响。 */
|
||||||
* latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */
|
|
||||||
export type UpdateChannel = 'stable' | 'beta';
|
export type UpdateChannel = 'stable' | 'beta';
|
||||||
|
|
||||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||||
@@ -471,9 +470,11 @@ export interface UserPreferences {
|
|||||||
startMinimized: boolean;
|
startMinimized: boolean;
|
||||||
/** UI theme preference: follow OS, light, or dark. */
|
/** UI theme preference: follow OS, light, or dark. */
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
/** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest;
|
/** 后台自动更新渠道。用户未明确选择时跟随当前构建类型;
|
||||||
* beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */
|
* About / Advanced 的手动检查按钮各自固定 stable/beta。 */
|
||||||
updateChannel: UpdateChannel;
|
updateChannel: UpdateChannel;
|
||||||
|
/** 是否由用户明确选择过更新渠道;缺失时由当前构建类型决定默认渠道。 */
|
||||||
|
updateChannelExplicit?: boolean;
|
||||||
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
|
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
|
||||||
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
|
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
|
||||||
* 到原一次性插入。默认 true。 */
|
* 到原一次性插入。默认 true。 */
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
|
|||||||
codingPlanX: 'codingPlanX',
|
codingPlanX: 'codingPlanX',
|
||||||
minimax: 'minimax',
|
minimax: 'minimax',
|
||||||
stepfun: 'stepfun',
|
stepfun: 'stepfun',
|
||||||
|
opencode: 'opencode',
|
||||||
custom: 'custom',
|
custom: 'custom',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CheckUpdateButton } from './CheckUpdateButton';
|
|||||||
export function BetaChannelSection() {
|
export function BetaChannelSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [channel, setChannel] = useState<UpdateChannel>('stable');
|
const [channel, setChannel] = useState<UpdateChannel>('stable');
|
||||||
|
const [autoCheckChannel, setAutoCheckChannel] = useState<UpdateChannel | null>(null);
|
||||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,7 +46,9 @@ export function BetaChannelSection() {
|
|||||||
await setUpdateChannel(target);
|
await setUpdateChannel(target);
|
||||||
} catch {
|
} catch {
|
||||||
setChannel(target === 'beta' ? 'stable' : 'beta');
|
setChannel(target === 'beta' ? 'stable' : 'beta');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setAutoCheckChannel(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (platformCaps?.supportsAutoUpdate !== true) return null;
|
if (platformCaps?.supportsAutoUpdate !== true) return null;
|
||||||
@@ -60,7 +63,7 @@ export function BetaChannelSection() {
|
|||||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||||
<CheckUpdateButton channel="beta" />
|
<CheckUpdateButton channel="beta" autoCheckChannel={autoCheckChannel} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const descriptors: ProviderDescriptor[] = [
|
|||||||
: 'api_key',
|
: 'api_key',
|
||||||
validationProbe: 'unsupported',
|
validationProbe: 'unsupported',
|
||||||
staticModels: [],
|
staticModels: [],
|
||||||
|
defaultRequestFormat: null,
|
||||||
|
supportedRequestFormats: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) =>
|
const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) =>
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface PresetOption {
|
|||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
authRequirement?: ProviderDescriptor['authRequirement'];
|
authRequirement?: ProviderDescriptor['authRequirement'];
|
||||||
staticModels?: string[];
|
staticModels?: string[];
|
||||||
|
defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat'];
|
||||||
|
supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
|
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
|
||||||
@@ -81,6 +83,8 @@ export function presetsFor(
|
|||||||
defaultModel: descriptor.defaultModel ?? undefined,
|
defaultModel: descriptor.defaultModel ?? undefined,
|
||||||
authRequirement: descriptor.authRequirement,
|
authRequirement: descriptor.authRequirement,
|
||||||
staticModels: descriptor.staticModels,
|
staticModels: descriptor.staticModels,
|
||||||
|
defaultRequestFormat: descriptor.defaultRequestFormat,
|
||||||
|
supportedRequestFormats: descriptor.supportedRequestFormats,
|
||||||
}));
|
}));
|
||||||
if (kind === 'llm') return descriptorPresets;
|
if (kind === 'llm') return descriptorPresets;
|
||||||
const available = descriptorPresets;
|
const available = descriptorPresets;
|
||||||
@@ -905,6 +909,7 @@ function ChannelModal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(channel.name);
|
const [name, setName] = useState(channel.name);
|
||||||
const [providerType, setProviderType] = useState(channel.providerType);
|
const [providerType, setProviderType] = useState(channel.providerType);
|
||||||
|
const [changingProvider, setChangingProvider] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const nameId = useId();
|
const nameId = useId();
|
||||||
@@ -1013,15 +1018,18 @@ function ChannelModal({
|
|||||||
const changeProvider = async (next: string) => {
|
const changeProvider = async (next: string) => {
|
||||||
const previous = providerType;
|
const previous = providerType;
|
||||||
onUserMutation();
|
onUserMutation();
|
||||||
setProviderType(next);
|
setChangingProvider(true);
|
||||||
try {
|
try {
|
||||||
await setChannelProviderType(kind, channel.id, next);
|
await setChannelProviderType(kind, channel.id, next);
|
||||||
await fillProviderDefaults(next);
|
await fillProviderDefaults(next);
|
||||||
|
setProviderType(next);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[channels] change provider failed', error);
|
console.error('[channels] change provider failed', error);
|
||||||
setProviderType(previous);
|
setProviderType(previous);
|
||||||
emitSaved('failed', t('common.operationFailed'));
|
emitSaved('failed', t('common.operationFailed'));
|
||||||
|
} finally {
|
||||||
|
setChangingProvider(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1090,6 +1098,7 @@ function ChannelModal({
|
|||||||
<ChannelFormRow label={t('settings.channels.providerLabel')}>
|
<ChannelFormRow label={t('settings.channels.providerLabel')}>
|
||||||
<SelectLite
|
<SelectLite
|
||||||
value={providerType}
|
value={providerType}
|
||||||
|
disabled={changingProvider}
|
||||||
onChange={(next) => void changeProvider(next)}
|
onChange={(next) => void changeProvider(next)}
|
||||||
options={presets.map((p) => ({
|
options={presets.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
@@ -1115,15 +1124,17 @@ function ChannelModal({
|
|||||||
</ChannelFormRow>
|
</ChannelFormRow>
|
||||||
|
|
||||||
{/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */}
|
{/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */}
|
||||||
<ChannelCredentialFields
|
{!changingProvider && (
|
||||||
key={`${channel.id}:${providerType}`}
|
<ChannelCredentialFields
|
||||||
kind={kind}
|
key={`${channel.id}:${providerType}`}
|
||||||
providerType={providerType}
|
kind={kind}
|
||||||
channelId={channel.id}
|
providerType={providerType}
|
||||||
descriptor={descriptor}
|
channelId={channel.id}
|
||||||
onTested={() => void onChanged()}
|
descriptor={descriptor}
|
||||||
onUserMutation={onUserMutation}
|
onTested={() => void onChanged()}
|
||||||
/>
|
onUserMutation={onUserMutation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isLocalEngine && (
|
{isLocalEngine && (
|
||||||
<p className="ol-channel-local-hint">{t('settings.channels.localEngineModelHint')}</p>
|
<p className="ol-channel-local-hint">{t('settings.channels.localEngineModelHint')}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import type { UpdateChannel } from '../../lib/ipc';
|
|||||||
export function CheckUpdateButton({
|
export function CheckUpdateButton({
|
||||||
channel,
|
channel,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
autoCheckChannel,
|
||||||
}: {
|
}: {
|
||||||
channel: UpdateChannel;
|
channel: UpdateChannel;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
autoCheckChannel?: UpdateChannel | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const updater = useAutoUpdate();
|
const updater = useAutoUpdate();
|
||||||
@@ -40,6 +42,12 @@ export function CheckUpdateButton({
|
|||||||
: 'settings.about.checkStableUpdateBtn';
|
: 'settings.about.checkStableUpdateBtn';
|
||||||
const label = checking ? t('settings.about.checkingUpdate') : t(labelKey);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -110,6 +118,7 @@ export function CheckUpdateButton({
|
|||||||
{isDialogStatus(status) && (
|
{isDialogStatus(status) && (
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
status={status}
|
status={status}
|
||||||
|
currentVersion={updater.currentVersion}
|
||||||
version={updater.version}
|
version={updater.version}
|
||||||
progress={updater.progress}
|
progress={updater.progress}
|
||||||
downloaded={updater.downloaded}
|
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';
|
import { presetsFor } from './ChannelList';
|
||||||
|
|
||||||
const atlascloudPreset = LLM_LABELS.find((p) => p.id === 'atlascloud');
|
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) {
|
if (!atlascloudPreset) {
|
||||||
throw new Error('Atlas Cloud LLM preset is missing');
|
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',
|
authRequirement: 'endpoint_model_optional_api_key',
|
||||||
validationProbe: 'asr_silence',
|
validationProbe: 'asr_silence',
|
||||||
staticModels: [],
|
staticModels: [],
|
||||||
|
defaultRequestFormat: null,
|
||||||
|
supportedRequestFormats: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 界面负责本地化、字段保存反馈,以及用户主动触发的模型拉取和验证。
|
// 界面负责本地化、字段保存反馈,以及用户主动触发的模型拉取和验证。
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
validateProviderCredentials,
|
validateProviderCredentials,
|
||||||
type ProviderDescriptor,
|
type ProviderDescriptor,
|
||||||
} from '../../lib/ipc';
|
} from '../../lib/ipc';
|
||||||
|
import { LlmProtocolFields } from './LlmProtocolFields';
|
||||||
import { emitSaved } from '../../lib/savedEvent';
|
import { emitSaved } from '../../lib/savedEvent';
|
||||||
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
|
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
|
||||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||||
@@ -135,7 +137,10 @@ export const LLM_LABELS = [
|
|||||||
['codingPlanX', 'codingPlanX'],
|
['codingPlanX', 'codingPlanX'],
|
||||||
['minimax', 'minimax'],
|
['minimax', 'minimax'],
|
||||||
['stepfun', 'stepfun'],
|
['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 }[];
|
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];
|
||||||
|
|
||||||
// 火山语音转写在资源 ID 为空时显示的默认值。
|
// 火山语音转写在资源 ID 为空时显示的默认值。
|
||||||
@@ -165,7 +170,12 @@ export function ChannelCredentialFields({
|
|||||||
descriptor?: Partial<
|
descriptor?: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
ProviderDescriptor,
|
ProviderDescriptor,
|
||||||
'authRequirement' | 'defaultEndpoint' | 'defaultModel' | 'staticModels'
|
| 'authRequirement'
|
||||||
|
| 'defaultEndpoint'
|
||||||
|
| 'defaultModel'
|
||||||
|
| 'staticModels'
|
||||||
|
| 'defaultRequestFormat'
|
||||||
|
| 'supportedRequestFormats'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
/** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */
|
/** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */
|
||||||
@@ -176,6 +186,18 @@ export function ChannelCredentialFields({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { prefs, updatePrefs } = useHotkeySettings();
|
const { prefs, updatePrefs } = useHotkeySettings();
|
||||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
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 [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||||
const unifiedBailian = providerType === 'bailian';
|
const unifiedBailian = providerType === 'bailian';
|
||||||
const [bailianModel, setBailianModel] = useState('');
|
const [bailianModel, setBailianModel] = useState('');
|
||||||
@@ -200,10 +222,15 @@ export function ChannelCredentialFields({
|
|||||||
|
|
||||||
const onLlmThinkingToggle = (enabled: boolean) => {
|
const onLlmThinkingToggle = (enabled: boolean) => {
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
void updatePrefs((current) => ({ ...current, llmThinkingEnabled: enabled })).catch((error) => {
|
onLlmMutation();
|
||||||
console.error('[settings] failed to update LLM thinking mode', error);
|
trackField('thinking', true);
|
||||||
emitSaved('failed', t('common.operationFailed'));
|
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 尚未返回或加载失败时,
|
// Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时,
|
||||||
@@ -218,6 +245,16 @@ export function ChannelCredentialFields({
|
|||||||
const codexOAuthSelected = descriptor?.authRequirement === 'o_auth';
|
const codexOAuthSelected = descriptor?.authRequirement === 'o_auth';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && (
|
||||||
|
<LlmProtocolFields
|
||||||
|
channelId={channelId}
|
||||||
|
defaultFormat={descriptor.defaultRequestFormat}
|
||||||
|
formats={descriptor.supportedRequestFormats}
|
||||||
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
|
onSaved={onTested}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{codexOAuthSelected ? (
|
{codexOAuthSelected ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -238,7 +275,8 @@ export function ChannelCredentialFields({
|
|||||||
provider={channelId}
|
provider={channelId}
|
||||||
mono
|
mono
|
||||||
mask
|
mask
|
||||||
onUserMutation={onUserMutation}
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
/>
|
/>
|
||||||
<CredentialField
|
<CredentialField
|
||||||
key={`${channelId}:endpoint`}
|
key={`${channelId}:endpoint`}
|
||||||
@@ -247,18 +285,21 @@ export function ChannelCredentialFields({
|
|||||||
provider={channelId}
|
provider={channelId}
|
||||||
placeholder={defaultEndpoint || 'https://your-endpoint/v1'}
|
placeholder={defaultEndpoint || 'https://your-endpoint/v1'}
|
||||||
defaultValue={defaultEndpoint || undefined}
|
defaultValue={defaultEndpoint || undefined}
|
||||||
onUserMutation={onUserMutation}
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
/>
|
/>
|
||||||
{providerType === 'custom' && (
|
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
|
||||||
<>
|
<>
|
||||||
<CredentialField
|
<CredentialField
|
||||||
key={`${channelId}:extra_headers`}
|
key={`${channelId}:extra_headers`}
|
||||||
label={t('settings.providers.extraHeadersLabel')}
|
label={t('settings.providers.extraHeadersLabel')}
|
||||||
account="ark.extra_headers"
|
account="ark.extra_headers"
|
||||||
|
provider={channelId}
|
||||||
placeholder={t('settings.providers.extraHeadersPlaceholder')}
|
placeholder={t('settings.providers.extraHeadersPlaceholder')}
|
||||||
mono
|
mono
|
||||||
mask
|
mask
|
||||||
onUserMutation={onUserMutation}
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +320,8 @@ export function ChannelCredentialFields({
|
|||||||
placeholder={defaultModel || 'model-name'}
|
placeholder={defaultModel || 'model-name'}
|
||||||
mono
|
mono
|
||||||
defaultValue={defaultModel || undefined}
|
defaultValue={defaultModel || undefined}
|
||||||
onUserMutation={onUserMutation}
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
trailing={
|
trailing={
|
||||||
<LlmThinkingToggle
|
<LlmThinkingToggle
|
||||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||||
@@ -287,17 +329,24 @@ export function ChannelCredentialFields({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{providerType === 'custom' && (
|
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
|
||||||
<CredentialField
|
<CredentialField
|
||||||
key={`${channelId}:temperature`}
|
key={`${channelId}:temperature`}
|
||||||
label={t('settings.providers.temperatureLabel')}
|
label={t('settings.providers.temperatureLabel')}
|
||||||
account="ark.temperature"
|
account="ark.temperature"
|
||||||
|
provider={channelId}
|
||||||
placeholder={t('settings.providers.temperaturePlaceholder')}
|
placeholder={t('settings.providers.temperaturePlaceholder')}
|
||||||
mono
|
mono
|
||||||
onUserMutation={onUserMutation}
|
onUserMutation={onLlmMutation}
|
||||||
|
onBlockedChange={trackField}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ProviderTools
|
<ProviderTools
|
||||||
|
key={configRevision}
|
||||||
|
disabled={
|
||||||
|
Object.values(blockedFields).some(Boolean) ||
|
||||||
|
(!!descriptor.supportedRequestFormats?.length && blockedFields.protocol === undefined)
|
||||||
|
}
|
||||||
kind="llm"
|
kind="llm"
|
||||||
modelAccount="ark.model_id"
|
modelAccount="ark.model_id"
|
||||||
provider={channelId}
|
provider={channelId}
|
||||||
@@ -765,6 +814,7 @@ function ProviderTools({
|
|||||||
onTested,
|
onTested,
|
||||||
onUserMutation,
|
onUserMutation,
|
||||||
showFetchModels = true,
|
showFetchModels = true,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
kind: 'llm' | 'asr' | 'omni';
|
kind: 'llm' | 'asr' | 'omni';
|
||||||
modelAccount: string;
|
modelAccount: string;
|
||||||
@@ -773,8 +823,16 @@ function ProviderTools({
|
|||||||
onTested?: () => void;
|
onTested?: () => void;
|
||||||
onUserMutation?: () => void;
|
onUserMutation?: () => void;
|
||||||
showFetchModels?: boolean;
|
showFetchModels?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [status, setStatus] = useState<ProviderToolStatus>('idle');
|
const [status, setStatus] = useState<ProviderToolStatus>('idle');
|
||||||
@@ -782,6 +840,7 @@ function ProviderTools({
|
|||||||
const [operation, setOperation] = useState<'validate' | 'models'>('validate');
|
const [operation, setOperation] = useState<'validate' | 'models'>('validate');
|
||||||
|
|
||||||
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
|
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
|
||||||
|
if (!mounted.current) return;
|
||||||
setStatus(next);
|
setStatus(next);
|
||||||
setMessage(nextMessage);
|
setMessage(nextMessage);
|
||||||
};
|
};
|
||||||
@@ -790,7 +849,7 @@ function ProviderTools({
|
|||||||
// 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。
|
// 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。
|
||||||
const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => {
|
const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => {
|
||||||
// Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。
|
// Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。
|
||||||
if (!provider || kind === 'omni') return;
|
if (!mounted.current || !provider || kind === 'omni') return;
|
||||||
try {
|
try {
|
||||||
await recordChannelTest(kind, provider, ok, latencyMs, message);
|
await recordChannelTest(kind, provider, ok, latencyMs, message);
|
||||||
onTested?.();
|
onTested?.();
|
||||||
@@ -800,6 +859,7 @@ function ProviderTools({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validate = async () => {
|
const validate = async () => {
|
||||||
|
if (disabled) return;
|
||||||
onUserMutation?.();
|
onUserMutation?.();
|
||||||
setOperation('validate');
|
setOperation('validate');
|
||||||
setResult('loading', t('settings.providers.validating'));
|
setResult('loading', t('settings.providers.validating'));
|
||||||
@@ -833,6 +893,7 @@ function ProviderTools({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadModels = async () => {
|
const loadModels = async () => {
|
||||||
|
if (disabled) return;
|
||||||
onUserMutation?.();
|
onUserMutation?.();
|
||||||
setOperation('models');
|
setOperation('models');
|
||||||
setResult('loading', t('settings.providers.loadingModels'));
|
setResult('loading', t('settings.providers.loadingModels'));
|
||||||
@@ -852,6 +913,7 @@ function ProviderTools({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyModel = async (model: string) => {
|
const applyModel = async (model: string) => {
|
||||||
|
if (disabled) return;
|
||||||
onUserMutation?.();
|
onUserMutation?.();
|
||||||
setOperation('models');
|
setOperation('models');
|
||||||
setResult('loading', t('common.saving'));
|
setResult('loading', t('common.saving'));
|
||||||
@@ -911,7 +973,7 @@ function ProviderTools({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={loadModels}
|
onClick={loadModels}
|
||||||
style={miniBtnStyle}
|
style={miniBtnStyle}
|
||||||
disabled={status === 'loading'}
|
disabled={disabled || status === 'loading'}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" size={14} />
|
<Icon name="refresh" size={14} />
|
||||||
{status === 'loading' && operation === 'models'
|
{status === 'loading' && operation === 'models'
|
||||||
@@ -922,7 +984,7 @@ function ProviderTools({
|
|||||||
<SelectLite
|
<SelectLite
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onChange={applyModel}
|
onChange={applyModel}
|
||||||
disabled={status === 'loading'}
|
disabled={disabled || status === 'loading'}
|
||||||
options={models.map((model) => ({ value: model, label: model }))}
|
options={models.map((model) => ({ value: model, label: model }))}
|
||||||
placeholder={t('settings.providers.selectModel')}
|
placeholder={t('settings.providers.selectModel')}
|
||||||
ariaLabel={t('settings.providers.selectModel')}
|
ariaLabel={t('settings.providers.selectModel')}
|
||||||
@@ -950,7 +1012,7 @@ function ProviderTools({
|
|||||||
color: 'var(--ol-blue)',
|
color: 'var(--ol-blue)',
|
||||||
borderColor: 'var(--ol-blue)',
|
borderColor: 'var(--ol-blue)',
|
||||||
}}
|
}}
|
||||||
disabled={status === 'loading'}
|
disabled={disabled || status === 'loading'}
|
||||||
>
|
>
|
||||||
<Icon name="play" size={13} />
|
<Icon name="play" size={13} />
|
||||||
{status === 'loading' && operation === 'validate'
|
{status === 'loading' && operation === 'validate'
|
||||||
@@ -966,6 +1028,17 @@ function ProviderTools({
|
|||||||
|
|
||||||
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
|
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
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:')) {
|
if (message.startsWith('providerHttpStatus:')) {
|
||||||
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
|
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1079,7 @@ type CredentialFieldStatus =
|
|||||||
'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
|
'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
|
||||||
|
|
||||||
interface CredentialFieldProps {
|
interface CredentialFieldProps {
|
||||||
|
onBlockedChange?: (account: string, blocked: boolean) => void;
|
||||||
label: string;
|
label: string;
|
||||||
account: string;
|
account: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -1033,6 +1107,7 @@ function CredentialField({
|
|||||||
onValueChange,
|
onValueChange,
|
||||||
onUserMutation,
|
onUserMutation,
|
||||||
options,
|
options,
|
||||||
|
onBlockedChange,
|
||||||
}: CredentialFieldProps) {
|
}: CredentialFieldProps) {
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -1043,9 +1118,22 @@ function CredentialField({
|
|||||||
const [status, setStatus] = useState<CredentialFieldStatus>('idle');
|
const [status, setStatus] = useState<CredentialFieldStatus>('idle');
|
||||||
// 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。
|
// 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。
|
||||||
const [customModelMode, setCustomModelMode] = useState(false);
|
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 debounceRef = useRef<number | null>(null);
|
||||||
const statusRef = useRef<number | null>(null);
|
const statusRef = useRef<number | null>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const editRevision = useRef(0);
|
||||||
|
const saveQueue = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const markMutation = () => {
|
||||||
|
editRevision.current += 1;
|
||||||
|
onUserMutation?.();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1109,22 +1197,28 @@ function CredentialField({
|
|||||||
const save = async (v: string, force = false) => {
|
const save = async (v: string, force = false) => {
|
||||||
if (!loaded || (!dirty && !force)) return;
|
if (!loaded || (!dirty && !force)) return;
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
const revision = editRevision.current;
|
||||||
setStatus('saving');
|
setStatus('saving');
|
||||||
emitSaved('saving', t('common.saving'));
|
emitSaved('saving', t('common.saving'));
|
||||||
try {
|
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);
|
setDirty(false);
|
||||||
showTemporaryStatus('saved');
|
showTemporaryStatus('saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current || revision !== editRevision.current) return;
|
||||||
console.error('[settings] failed to save credential', account, error);
|
console.error('[settings] failed to save credential', account, error);
|
||||||
showTemporaryStatus('saveError');
|
showTemporaryStatus('saveError');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onUserMutation?.();
|
markMutation();
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setValue(v);
|
setValue(v);
|
||||||
onValueChange?.(v);
|
onValueChange?.(v);
|
||||||
@@ -1145,7 +1239,7 @@ function CredentialField({
|
|||||||
|
|
||||||
const fillDefault = async () => {
|
const fillDefault = async () => {
|
||||||
if (!loaded || !defaultValue) return;
|
if (!loaded || !defaultValue) return;
|
||||||
onUserMutation?.();
|
markMutation();
|
||||||
setValue(defaultValue);
|
setValue(defaultValue);
|
||||||
onValueChange?.(defaultValue);
|
onValueChange?.(defaultValue);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -1193,7 +1287,7 @@ function CredentialField({
|
|||||||
setCustomModelMode(true);
|
setCustomModelMode(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onUserMutation?.();
|
markMutation();
|
||||||
setValue(v);
|
setValue(v);
|
||||||
onValueChange?.(v);
|
onValueChange?.(v);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
|
|||||||
@@ -111,9 +111,8 @@
|
|||||||
--ol-card-border: #e4e4e7;
|
--ol-card-border: #e4e4e7;
|
||||||
--ol-card-shadow:
|
--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;
|
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 主区非不透明分支用,
|
/* Keep text surfaces opaque; texture is applied above this solid base. */
|
||||||
light/dark 取值不同需保持主题感知。 */
|
--ol-panel-glass-bg: #ffffff;
|
||||||
--ol-panel-glass-bg: rgba(255, 255, 255, 0.94);
|
|
||||||
|
|
||||||
/* (参考 Codex 侧栏):侧栏底色与右侧纯白内容区拉开明确层次,
|
/* (参考 Codex 侧栏):侧栏底色与右侧纯白内容区拉开明确层次,
|
||||||
此前 #fafafa 对白底几乎无区分。 */
|
此前 #fafafa 对白底几乎无区分。 */
|
||||||
@@ -246,7 +245,7 @@
|
|||||||
--ol-bg-base: #09090b;
|
--ol-bg-base: #09090b;
|
||||||
--ol-bg-elevated: #141417;
|
--ol-bg-elevated: #141417;
|
||||||
/* 玻璃面板背景暗色取值(近实色)。 */
|
/* 玻璃面板背景暗色取值(近实色)。 */
|
||||||
--ol-panel-glass-bg: rgba(24, 24, 27, 0.96);
|
--ol-panel-glass-bg: #18181b;
|
||||||
|
|
||||||
--ol-ink: #fafafa;
|
--ol-ink: #fafafa;
|
||||||
--ol-ink-2: #d4d4d8;
|
--ol-ink-2: #d4d4d8;
|
||||||
|
|||||||
Reference in new issue
Block a user