From 4cfacf1c41b5a5b8c30ce2c5958f8e6493688d87 Mon Sep 17 00:00:00 2001 From: zhou-zhichao Date: Wed, 2 Sep 2026 03:10:49 +0200 Subject: [PATCH] feat(llm): add OpenCode Zen polish provider, default deepseek-v4-flash Dictation polish uses OpenAI-compatible chat/completions. For OpenCode Go, change the Base URL to /zen/go/v1. --- README.md | 2 +- README.zh.md | 2 +- .../app/src-tauri/src/commands/credentials.rs | 1 + openless-all/app/src-tauri/src/polish.rs | 38 ++++++++++++++++++- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Overview.tsx | 1 + .../pages/settings/ProvidersSection.test.ts | 20 ++++++++++ .../src/pages/settings/ProvidersSection.tsx | 10 +++++ 12 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1976a4737..7febed707 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every item below is one more layer sedimented into a default — a capability yo - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. - **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index 49af001c0..a510ab5c0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -204,7 +204,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 - **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 3a5356887..b4417ce48 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -151,6 +151,7 @@ fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> { "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), + "opencode" => Some("https://opencode.ai/zen/v1"), _ => None, } } diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d868641fc..1facaa80a 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -176,6 +176,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -1875,7 +1876,7 @@ pub(crate) enum ThinkingControl { pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { match provider_id.trim() { - "deepseek" => Some(ThinkingControl::DeepSeekThinking), + "deepseek" | "opencode" => Some(ThinkingControl::DeepSeekThinking), // provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。 "minimax" => Some(ThinkingControl::MiniMaxThinking), "openrouterFree" => Some(ThinkingControl::OpenRouterReasoning), @@ -1911,7 +1912,7 @@ pub(crate) fn openai_compatible_thinking_control_for_base_url( if host.contains("minimax") { return Some(ThinkingControl::MiniMaxThinking); } - if host.contains("deepseek") { + if host.contains("deepseek") || host.contains("opencode.ai") { return Some(ThinkingControl::DeepSeekThinking); } if host.contains("openrouter") { @@ -3619,6 +3620,39 @@ mod tests { assert_eq!(body["thinking"]["type"], "disabled"); } + #[test] + fn openai_chat_body_adds_deepseek_thinking_toggle_for_opencode() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "opencode", + "OpenCode Zen", + "https://opencode.ai/zen/v1", + "k", + "deepseek-v4-flash", + )); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + + #[test] + fn openai_chat_body_falls_back_to_base_url_for_custom_opencode_endpoint() { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "https://opencode.ai/zen/go/v1", + "k", + "deepseek-v4-flash", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["thinking"]["type"], "disabled"); + } + #[test] fn openai_chat_body_disables_minimax_thinking_by_preset() { // provider_id 预设命中 "minimax" → 走 MiniMaxThinking 分支,关闭时下发 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 42192c2ed..51d9dd521 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian realtime ASR', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2b5463ff6..b55ea9cd7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian リアルタイム ASR', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3ad19f3b..710b6ba68 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian 실시간 ASR', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 816224d3e..a577ad67f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + opencode: 'OpenCode Zen', custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里云百炼实时 ASR', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 534421198..ae7503bb6 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里雲百煉即時 ASR', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6eeddd610..7f5ad7bf8 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -52,6 +52,7 @@ const LLM_NAME_KEY_BY_ID: Record = { openrouterFree: 'openrouterFree', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', + opencode: 'opencode', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 371d5e4f2..4e0cc1265 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -15,6 +15,26 @@ if (atlascloudPreset.modelPlaceholder !== 'qwen/qwen3.5-flash') { throw new Error(`unexpected Atlas Cloud default model: ${atlascloudPreset.modelPlaceholder}`); } +const opencodePreset = LLM_PRESETS.find(p => p.id === 'opencode'); + +if (!opencodePreset) { + throw new Error('OpenCode LLM preset is missing'); +} + +if (opencodePreset.baseUrl !== 'https://opencode.ai/zen/v1') { + throw new Error(`unexpected OpenCode base URL: ${opencodePreset.baseUrl}`); +} + +if (opencodePreset.modelPlaceholder !== 'deepseek-v4-flash') { + throw new Error(`unexpected OpenCode default model: ${opencodePreset.modelPlaceholder}`); +} + +const customPresetIndex = LLM_PRESETS.findIndex(p => p.id === 'custom'); +const opencodePresetIndex = LLM_PRESETS.findIndex(p => p.id === 'opencode'); +if (customPresetIndex < 0 || opencodePresetIndex < 0 || opencodePresetIndex >= customPresetIndex) { + throw new Error('OpenCode preset must sit before the custom fallback preset'); +} + const openAiCompatiblePreset = ASR_PRESETS.find(p => p.id === 'openai-compatible'); if (!openAiCompatiblePreset) { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a315b7662..c37b3b658 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -188,6 +188,16 @@ export const LLM_PRESETS = [ baseUrl: 'https://api.stepfun.com/v1', modelPlaceholder: 'step-1o-turbo-vision', }, + { + // OpenCode Zen:OpenAI 兼容 /zen/v1/chat/completions。 + // 默认模型 deepseek-v4-flash(官方 model id,见 https://opencode.ai/docs/zen)。 + // OpenCode Go 订阅把 Base URL 改成 https://opencode.ai/zen/go/v1,Key 用 Go 控制台那把。 + // provider_id 在后端 polish.rs 命中 "opencode" → DeepSeekThinking(默认模型是 Flash)。 + id: 'opencode', + nameKey: 'opencode', + baseUrl: 'https://opencode.ai/zen/v1', + modelPlaceholder: 'deepseek-v4-flash', + }, { id: 'custom', nameKey: 'custom',