diff --git a/docs/provider-channels-plan.md b/docs/provider-channels-plan.md new file mode 100644 index 000000000..24b0f1e49 --- /dev/null +++ b/docs/provider-channels-plan.md @@ -0,0 +1,184 @@ +# 供应商渠道卡片化 实施计划 + +> 状态:设计讨论中(尚未动手) +> 日期:2026-08-04 +> 范围:设置 → AI 提供商,LLM 润色 + ASR 语音转写 +> 参考:[Calcium-Ion/new-api](https://github.com/Calcium-Ion/new-api) 的 Channel 模型与重试策略 + +## 1. 要解决的问题 + +今天一个供应商只能存一份配置(一把 key、一个 endpoint、一个模型)。实际使用中: + +1. **同一家有多把 key**(主号 / 备号 / 白嫖号),现在只能存一把,换 key 靠手动覆盖粘贴 +2. **key 之间要频繁切换**,切换过程中旧配置就丢了 +3. 某把 key 被限流(429)时没有任何自动应对,整条润色链路直接失败 + +目标:把配置从"一个供应商一个槽"变成"一张张可命名、可排序、可开关的卡片",并让失败能自动顺延到下一张卡片。 + +## 2. 现状核对 + +| 事实 | 位置 | +| --- | --- | +| 存储层已经是 `HashMap`,key 是 preset id | `credentials.rs:169` | +| `CredsLlmEntry` 已有 `displayName` 字段,前端从未使用 | `credentials.rs:257` | +| ASR 凭据按 provider 隔离正确(空槽才填默认值) | `ProvidersSection.tsx:370` | +| LLM 切 preset 会**强制覆盖** endpoint/model,注释所述的"共用槽"bug 早已不成立 | `ProvidersSection.tsx:305` | +| 全局零重试 / 零故障转移(`rg retry\|backoff\|fallback` 无命中) | — | +| 凭据读取是**隐式全局** `CredentialsVault::get(...)` 去查 `root.active.*`,调用方无法指定渠道 | coordinator.rs / commands/providers.rs 共数十处 | +| ASR provider id 同时承担**协议路由 key**(百炼一个 id 分三协议,stepfun 分两协议) | `coordinator.rs:341` | +| 新手引导直接嵌 `` | `Onboarding.tsx:208` | +| Windows 默认 ASR 是本地 Foundry(无需 key,开箱即用) | `credentials.rs:155` | +| LLM 单次请求超时 30s | `polish.rs:23` | + +**结论**:存储结构不用推倒,改 key 语义即可;真正的成本在"凭据显式化"这次重构。 + +## 3. 已定的设计决策 + +| 决策 | 结论 | +| --- | --- | +| 范围 | LLM 与 ASR **都**做卡片 | +| 排序 | 列表可拖拽,越靠上越优先;启用列表的**第一个 = 当前使用** | +| 开关 | 打开 = 加入重试队列;**关掉自动沉到列表末尾**;重新打开回到启用组末尾 | +| 触发切换 | 429 等错误**立即**切下一个渠道 | +| 超时 | **不触发**切换(本期先这样) | +| 渠道失败 | **只在卡片上标红**(如「上次失败 · 401 · 3 分钟前」),**不自动禁用** | +| 全部失败 | 润色链路降级为**直接插入 ASR 原文** + 右上角提示 | +| 特殊项 | 本地引擎(qwen3 / sherpa / Apple 语音 / Foundry)与 Codex OAuth **不做预置固定卡片**,它们是「+添加渠道」供应商下拉里的普通选项,选中即长出卡片,表单里没有 key/地址字段 | + +### 3.1 ASR 与 LLM 语义统一 + +429 只出现在**建连 / 鉴权阶段**——此时一个字都还没吐出来,音频缓冲尚未被消费,换渠道重连是安全的。因此两边共用同一套心智: + +> 排序 = 优先级;开关 = 在不在重试队列;失败(非超时)顺延下一个。 + +ASR 唯一的额外规则:**一旦开始出字就不再切换**,之后连接断了就是断了(流式已吐字,回滚会造成文字重复或跳变)。 + +### 3.2 429 冷却(必须有) + +若不加冷却,限流期间**每一次**听写都会白赔一次「打 1 号 → 429 → 打 2 号」的往返(数百毫秒,同步链路里能感知)。 + +- 渠道返回 429 → 打 **60 秒冷却**,冷却期内直接跳过 +- 冷却是**内存态**,不落盘,重启即清 +- 卡片上显示「限流中 · 47s」小字,到期自动恢复,无需用户干预 + +### 3.3 超时值下调(独立改动) + +保留"超时不切换"的规则,但把 `DEFAULT_REQUEST_TIMEOUT_SECS` 从 **30s 压到 8s**。润色是用户盯着屏幕等的同步链路,8 秒未返回的渠道等下去没有意义。 + +## 4. 数据模型 + +```rust +struct Channel { + id: String, // uuid,取代 preset id 作为 map key + name: String, // 用户取的名字,如「硅基流动-主号」 + provider_type: String, // deepseek / volcengine / sherpa-onnx-local / codex_oauth ... + // 决定协议路由 + 表单形状,必须独立于 id + enabled: bool, + order: u32, // 拖拽排序;关掉时自动置到末尾 + last_error: Option, // { kind, message, at } —— 卡片标红用 + last_test: Option, // { ok, latency_ms, at } —— 连通测试结果 + // 凭据字段沿用现有 CredsAsrEntry / CredsLlmEntry,按 provider_type 决定渲染哪些 +} + +// 仅内存,不落盘 +struct ChannelRuntime { + cooldown_until: Option, // 429 临时冷却 +} +``` + +**`provider_type` 必须独立于 `id`**:否则 `coordinator.rs:341` 那条"按 provider id + 模型名路由到具体协议实现"的链会断——这是漏了就整个 ASR 挂掉的点。 + +`active.llm` / `active.asr` 两个字段退休,"当前使用"= 启用列表的第一个。 + +## 5. 迁移 + +1. 遍历现有 `providers.llm` / `providers.asr` 的每个非空 entry,各生成一张卡片 + - `id` = 新 uuid,`provider_type` = 原 map key,`name` = `displayName` 或 preset 显示名 +2. 原 `active.llm` / `active.asr` 指向的那张排到 **order = 0**,其余按 ASR_PRESETS / LLM_PRESETS 原顺序跟随 +3. 全部默认 `enabled = true` +4. **全新安装**(无任何 entry):按平台预置 + - Windows → 一张 Foundry 本地 ASR 卡片(保住开箱即用) + - mac / Linux → 不预置,走引导 +5. 迁移必须幂等,且失败时保留原 JSON 不动(参考现有 `load_credentials_for_update` 的写法) + +## 6. 重试策略 + +> **状态:未实现,属于 P2。** P0 里一次失败就是一次失败,不会换渠道。 +> 下表是已定但**尚未落地**的目标行为。 + +### 6.0 代码里已经存在的两样东西(别和渠道故障转移混为一谈) + +排查时容易在代码里搜到 `retry` 就以为做了,这两处都是**既有代码**,与渠道无关: + +1. **`net.rs::send_with_retry` —— 连接层重连,不是渠道切换。** + 只对 `err.is_connect()`(TCP 握手被拒 / 连接重置,请求**尚未送达**服务端)重试, + 150/300/600/900ms 退避。**拿到任何 HTTP 响应就直接返回**(含 429/401/5xx), + 超时明确不重试。它重连的始终是同一个 endpoint,永远不会换到另一张卡片。 + +2. **润色失败已经会回落 ASR 原文。** + `coordinator/polish_flow.rs::polish_or_passthrough` 的失败分支: + ```rust + Err(e) => { + log::error!("[coord] polish failed, falling back to raw: {reason}"); + (raw.text.clone(), Some(reason)) + } + ``` + 也就是说,「全部渠道试完仍失败 → 插入 ASR 原文」这条决策**天然满足**, + P2 要做的只是在回落之前多试几张卡片,而不是新建一条兜底路径。 + +### 6.1 目标行为(P2) + +照搬 New API `shouldRetry()` 的分类,按桌面场景裁剪: + +| 情况 | 行为 | +| --- | --- | +| 429 | **切下一个** + 当前渠道 60s 冷却 | +| 401 / 403 | **切下一个** + 卡片标红(不自动禁用) | +| 5xx / 连接失败 | **切下一个** + 卡片标红 | +| 超时 | **不切**,直接失败(本期决策) | +| 400 参数错误 | **不切**(换渠道多半是同样的错) | +| 2xx | 成功 | +| 全部启用渠道试完仍失败 | 插入 ASR 原文 + 提示 | + +**不抄** New API 的:`Weight` 加权负载均衡(单用户无负载可均衡,随机选渠道反而让"在用哪个"不可预测)、`Group` / `UsedQuota` / `Balance`(多租户计费概念)、`AutoBan`(桌面软件静默关用户配置会让人一脸懵)。 + +**缓一缓**:`ModelMapping` / `ParamOverride`,有用但非第一版必需。 + +## 7. UI + +``` +┌─ LLM 润色 ──────────────────────────────┐ +│ ⠿ ● 硅基流动-主号 deepseek-v4 28ms ⋮ │ ← 生效中 +│ ⠿ ○ Ark-备用 deepseek-v3-2 — ⋮ │ ← 备用 +│ ⠿ ○ 阶跃星辰 (限流中 · 47s) ⋮ │ ← 429 冷却 +│ ⠿ ⊘ OpenAI (上次失败 · 401) ⋮ │ ← 已关闭,沉底 +│ + 添加渠道 │ +└──────────────────────────────────────────┘ +``` + +添加/编辑弹窗:名字 → 选供应商(自动填 baseUrl / 模型占位)→ 按 `provider_type` 渲染凭据字段 → 「测试连通」→ 保存。 + +可复用的现成件: +- `validateProviderCredentials` / `listProviderModels`(`ProvidersSection.tsx:854` 起) +- 按 provider 分支渲染凭据字段的逻辑(火山双鉴权模式、讯飞双字段、百炼词表等) +- 本地引擎卡片的编辑弹窗内嵌 ``(`LocalAsr/index.tsx:122`) + +**新手引导**:列表为空时直接摊开添加表单,跳过空态与加号,省一次点击。 + +## 8. 分期 + +| 期 | 内容 | 可否独立发布 | +| --- | --- | --- | +| **P0** | 渠道数据模型 + 迁移 + 卡片 UI + 拖拽排序 + 测试连通。**不做重试** | ✅ 独立故事:「我有两把 key,想随手切」 | +| | ↑ 已完成。**此时多渠道的价值是"存档 + 手动切换",不是自动容错**:排在第二的卡片永远不会被自动用上,要用得手动拖到第一位。 | | +| **P1** | 凭据显式化重构:`CredentialsVault::get(...)` → 上层解析 `ResolvedChannel` 显式下传 | ❌ 纯重构,无用户可见变化,P2 前提 | +| **P2** | 重试 + 故障转移 + 429 冷却 + 超时下调 + 全挂兜底 | ✅ | + +P1 是本需求最大的单块工作量,比卡片 UI 大得多。P1 + P2 合成第二个 PR。 + +## 9. 待确认 + +- [ ] 拖拽排序用什么实现(现有依赖里有没有可用的,还是手写 HTML5 drag) +- [ ] 卡片列表的移动端(Android)形态 +- [ ] Android 的 `android_credentials.rs` 加密信封是否需要同步改版本号 +- [ ] P0 的验收判据(建议:能建 3 张同供应商不同 key 的卡片、重启后顺序与内容不丢、拖拽后生效的是第一张) diff --git a/openless-all/app/src-tauri/src/commands/channels.rs b/openless-all/app/src-tauri/src/commands/channels.rs new file mode 100644 index 000000000..c3fe40fa3 --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/channels.rs @@ -0,0 +1,157 @@ +//! 渠道卡片管理的 IPC 面。 +//! +//! 一张卡片 = 一份可命名、可排序、可开关的供应商配置。同一家厂商可以有多张卡片 +//! (多把 key),此时渠道 id 与 `providerType` 分离 —— 前者是 map key,后者决定 +//! 协议路由。详见 `persistence::credentials` 里 `ChannelMeta` 的说明。 +//! +//! 凭据本身不走这里:前端按渠道 id 调 `read_credential` / `set_credential` +//! (`provider` 参数传渠道 id),避免密钥随列表批量出栈。 + +use super::*; +use crate::persistence::{ChannelKind, ChannelSummary}; + +fn parse_kind(kind: &str) -> Result { + ChannelKind::parse(kind).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn list_channels(window: Window, kind: String) -> Result, String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || CredentialsVault::list_channels(kind)) + .await + .map_err(|e| format!("channel list worker failed: {e}")) +} + +#[tauri::command] +pub async fn create_channel( + window: Window, + kind: String, + provider_type: String, + name: String, +) -> Result { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::create_channel(kind, &provider_type, &name).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel create worker failed: {e}"))? +} + +#[tauri::command] +pub async fn set_channel_provider_type( + window: Window, + kind: String, + id: String, + provider_type: String, +) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::set_channel_provider_type(kind, &id, &provider_type) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel provider type worker failed: {e}"))? +} + +/// 关闭「添加渠道」弹窗时回收没填任何内容的草稿卡片;返回是否真的删了。 +#[tauri::command] +pub async fn delete_channel_if_blank( + window: Window, + kind: String, + id: String, +) -> Result { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::delete_channel_if_blank(kind, &id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel cleanup worker failed: {e}"))? +} + +#[tauri::command] +pub async fn rename_channel( + window: Window, + kind: String, + id: String, + name: String, +) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::rename_channel(kind, &id, &name).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel rename worker failed: {e}"))? +} + +#[tauri::command] +pub async fn delete_channel(window: Window, kind: String, id: String) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::delete_channel(kind, &id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel delete worker failed: {e}"))? +} + +#[tauri::command] +pub async fn set_channel_enabled( + window: Window, + kind: String, + id: String, + enabled: bool, +) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::set_channel_enabled(kind, &id, enabled).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel toggle worker failed: {e}"))? +} + +#[tauri::command] +pub async fn reorder_channels( + window: Window, + kind: String, + ids: Vec, +) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::reorder_channels(kind, &ids).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel reorder worker failed: {e}"))? +} + +/// 记录一次「测试连通」的结果,供卡片显示延迟或标红。 +/// +/// 时间戳在后端取,不信任前端传入 —— 前端时钟错乱会让"3 分钟前"显示成负数。 +#[tauri::command] +pub async fn record_channel_test( + window: Window, + kind: String, + id: String, + ok: bool, + latency_ms: Option, + error: Option, +) -> Result<(), String> { + ensure_main_window(&window)?; + let kind = parse_kind(&kind)?; + let at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + tauri::async_runtime::spawn_blocking(move || { + CredentialsVault::record_channel_test(kind, &id, ok, latency_ms, at, error) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("channel test record worker failed: {e}"))? +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index a8e82d3db..1866e611f 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -234,25 +234,14 @@ pub async fn set_credential( } let acc = parsed.expect("non-extra credential account must be parsed"); if let Some(provider) = provider { - if !matches!( - acc, - CredentialAccount::VolcengineAppKey - | CredentialAccount::VolcengineAccessKey - | CredentialAccount::VolcengineResourceId - | CredentialAccount::VolcengineAuthMode - | CredentialAccount::VolcengineApiKey - | CredentialAccount::AsrApiKey - | CredentialAccount::AsrEndpoint - | CredentialAccount::AsrModel - | CredentialAccount::AsrVocabularyId - | CredentialAccount::AsrAdvancedConfig - | CredentialAccount::XfyunAppId - | CredentialAccount::XfyunApiKey - ) { - return Err("provider-scoped credential must be an ASR account".to_string()); + // 渠道化后 `provider` 是**渠道 id**,LLM 侧同样需要按 id 定位 —— 用户编辑 + // 的可能是列表里第 3 张卡片,而不是当前生效的那张。 + match account_channel_kind(acc) { + ChannelKind::Asr => CredentialsVault::set_for_asr_provider(&provider, acc, &value) + .map_err(|e| e.to_string()), + ChannelKind::Llm => CredentialsVault::set_for_llm_provider(&provider, acc, &value) + .map_err(|e| e.to_string()), } - CredentialsVault::set_for_asr_provider(&provider, acc, &value) - .map_err(|e| e.to_string()) } else if value.is_empty() { CredentialsVault::remove(acc).map_err(|e| e.to_string()) } else { @@ -373,7 +362,12 @@ pub async fn read_credential( } let acc = parsed.expect("non-extra credential account must be parsed"); if let Some(provider) = provider { - CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string()) + match account_channel_kind(acc) { + ChannelKind::Asr => CredentialsVault::get_for_asr_provider(&provider, acc) + .map_err(|e| e.to_string()), + ChannelKind::Llm => CredentialsVault::get_for_llm_provider(&provider, acc) + .map_err(|e| e.to_string()), + } } else { CredentialsVault::get(acc).map_err(|e| e.to_string()) } @@ -382,7 +376,33 @@ pub async fn read_credential( .map_err(|e| format!("credential read worker failed: {e}"))? } -fn ensure_main_window(window: &Window) -> Result<(), String> { +/// 一个凭据账户属于 ASR 面还是 LLM 面 —— 决定按渠道 id 定位时查哪张 map。 +fn account_channel_kind(account: CredentialAccount) -> ChannelKind { + match account { + CredentialAccount::ArkApiKey + | CredentialAccount::ArkModelId + | CredentialAccount::ArkEndpoint => ChannelKind::Llm, + CredentialAccount::VolcengineAppKey + | CredentialAccount::VolcengineAccessKey + | CredentialAccount::VolcengineResourceId + | CredentialAccount::VolcengineAuthMode + | CredentialAccount::VolcengineApiKey + | CredentialAccount::AsrApiKey + | CredentialAccount::AsrEndpoint + | CredentialAccount::AsrModel + | CredentialAccount::AsrVocabularyId + | CredentialAccount::AsrAdvancedConfig + | CredentialAccount::XfyunAppId + | CredentialAccount::XfyunApiKey => ChannelKind::Asr, + // Omni 凭据走独立命名空间、从不按渠道 id 定位(前端写入不带 provider); + // 映射到 Asr 只为穷尽 match,实际调用点不可达。 + CredentialAccount::OmniApiKey + | CredentialAccount::OmniEndpoint + | CredentialAccount::OmniModel => ChannelKind::Asr, + } +} + +pub(crate) fn ensure_main_window(window: &Window) -> Result<(), String> { if window.label() == "main" { Ok(()) } else { diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 86a6c6e69..e9d65bd2d 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -43,8 +43,8 @@ pub(crate) use crate::coordinator::Coordinator; pub(crate) use crate::net; pub(crate) use crate::permissions::{self, PermissionStatus}; pub(crate) use crate::persistence::{ - sync_style_pack_preferences, CredentialAccount, CredentialsSnapshot, CredentialsVault, - PreferencesStore, + sync_style_pack_preferences, ChannelKind, CredentialAccount, CredentialsSnapshot, + CredentialsVault, PreferencesStore, }; pub(crate) use crate::polish::{ http_client_builder, openai_compatible_temperature_for_provider, CodexOAuthConfig, @@ -64,6 +64,7 @@ pub(crate) use crate::types::{ StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore, }; +mod channels; mod credentials; mod dictation; mod dictionary; @@ -90,6 +91,7 @@ mod selection_polish; mod selection_polish_preview; mod style_packs; +pub use channels::*; pub use credentials::*; pub use dictation::*; pub use dictionary::*; diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 3a600edae..d058d46da 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -2,6 +2,61 @@ use super::*; use base64::Engine; use std::collections::HashMap; +/// 一次连通测试 / 模型列表请求所针对的渠道。 +/// +/// 渠道化之前这两条路径都隐式读"当前生效"的凭据;卡片化之后用户会对列表里**任意** +/// 一张卡片点「测试连通」,包括还没轮到它生效的那些。`channel = None` 保留旧语义 +/// (当前生效的渠道),供未指定渠道的老调用点使用。 +/// +/// 注意这只覆盖测试与模型列表两条路径 —— 真正的听写 / 润色链路仍走隐式 active, +/// 那部分的显式化是 P1 的工作(见 docs/provider-channels-plan.md)。 +pub(crate) struct ProviderScope { + kind: ChannelKind, + channel: Option, +} + +impl ProviderScope { + fn new(kind: &str, channel: Option) -> Result { + let kind = ChannelKind::parse(kind).map_err(|e| e.to_string())?; + Ok(Self { kind, channel }) + } + + /// 读该渠道的凭据;未指定渠道时回落到当前生效的那张。 + fn get(&self, account: CredentialAccount) -> Result, String> { + match (&self.channel, self.kind) { + (Some(id), ChannelKind::Asr) => CredentialsVault::get_for_asr_provider(id, account), + (Some(id), ChannelKind::Llm) => CredentialsVault::get_for_llm_provider(id, account), + (None, _) => CredentialsVault::get(account), + } + .map_err(|e| e.to_string()) + } + + /// 该渠道的厂商 id —— 决定走哪套协议。 + fn provider_type(&self) -> String { + match (&self.channel, self.kind) { + (Some(id), kind) => { + CredentialsVault::get_channel_provider_type(kind, id).unwrap_or_else(|| id.clone()) + } + (None, ChannelKind::Asr) => CredentialsVault::get_active_asr(), + (None, ChannelKind::Llm) => CredentialsVault::get_active_llm(), + } + } + + fn llm_extra_headers(&self) -> HashMap { + match &self.channel { + Some(id) => CredentialsVault::get_llm_extra_headers_for_channel(id), + None => CredentialsVault::get_active_llm_extra_headers(), + } + } + + fn llm_temperature(&self) -> Option { + match &self.channel { + Some(id) => CredentialsVault::get_llm_temperature_for_channel(id), + None => CredentialsVault::get_active_llm_temperature(), + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderCheckResult { @@ -13,13 +68,27 @@ pub struct ProviderModelsResult { models: Vec, } +/// `channel_id = None` 时测当前生效的渠道(老行为);卡片上的「测试连通」会带上 +/// 那张卡片的 id,这样还没轮到生效的渠道也能验证。 #[tauri::command] -pub async fn validate_provider_credentials(kind: String) -> Result { +pub async fn validate_provider_credentials( + kind: String, + channel_id: Option, +) -> Result { + if kind == "omni" { + // Omni 走独立命名空间,不做渠道化(见 docs/provider-channels-plan.md 分期); + // 校验是真实纯文本请求,与运行期完全同路径。 + return validate_omni_provider() + .await + .map(|()| ProviderCheckResult { ok: true }); + } + let scope = ProviderScope::new(&kind, channel_id)?; + let scope = &scope; match kind.as_str() { - "llm" => validate_llm_provider() + "llm" => validate_llm_provider(scope) .await .map(|()| ProviderCheckResult { ok: true }), - "asr" => validate_asr_provider() + "asr" => validate_asr_provider(scope) .await .map(|()| ProviderCheckResult { ok: true }), "omni" => validate_omni_provider() @@ -30,14 +99,19 @@ pub async fn validate_provider_credentials(kind: String) -> Result Result { - if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID { +pub async fn list_provider_models( + kind: String, + channel_id: Option, +) -> Result { + let scope = ProviderScope::new(&kind, channel_id)?; + let scope = &scope; + if kind == "asr" && scope.provider_type() == crate::asr::bailian::PROVIDER_ID { // 统一「阿里云百炼」入口:三条协议(实时 fun-asr-realtime / 实时 qwen3 / // 录音文件 fun-asr-flash)收成一个 provider。百炼各网关都没有模型列表 HTTP // 接口,列表是静态的;但先跑一次与「验证」相同的、按当前所选模型对应协议的 // 连通性检查(validate_asr_provider 已按模型路由),避免 Key/endpoint 全错时 // 也显示成功。随后返回三个可选模型供下拉。 - validate_asr_provider().await?; + validate_asr_provider(scope).await?; // 静态清单只是常用快捷项;协议按模型名自动路由,用户也可在模型框直接手填 // 已支持的 DashScope ASR 模型;不支持的模型会在验证/开始录音前明确拒绝。 return Ok(ProviderModelsResult { @@ -59,11 +133,10 @@ pub async fn list_provider_models(kind: String) -> Result Result Result Result, } -fn read_openai_provider_config(kind: &str) -> Result { +fn read_openai_provider_config( + kind: &str, + scope: &ProviderScope, +) -> Result { // `openai-compatible` 允许 API Key 留空(LAN 无鉴权端点);其余 ASR 提供商 // 仍必填,与运行时门禁 ensure_asr_credentials 保持一致。 let (api_key_account, endpoint_account, api_key_required) = match kind { @@ -129,8 +203,7 @@ fn read_openai_provider_config(kind: &str) -> Result { "asr" => ( CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint, - CredentialsVault::get_active_asr() - != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, + scope.provider_type() != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, ), // 多模态(Omni)模型:独立命名空间,OpenAI 兼容通道要求 API Key + Base URL。 "omni" => ( @@ -140,20 +213,19 @@ fn read_openai_provider_config(kind: &str) -> Result { ), _ => return Err(format!("unknown provider kind: {kind}")), }; - let api_key = CredentialsVault::get(api_key_account) + let api_key = scope + .get(api_key_account) .map_err(|e| e.to_string())? .unwrap_or_default(); - let base_url = CredentialsVault::get(endpoint_account) + let base_url = scope + .get(endpoint_account) .map_err(|e| e.to_string())? .unwrap_or_default(); let (extra_headers, temperature) = if kind == "llm" { - let active_llm = CredentialsVault::get_active_llm(); + let active_llm = scope.provider_type(); ( - CredentialsVault::get_active_llm_extra_headers(), - openai_compatible_temperature_for_provider( - &active_llm, - CredentialsVault::get_active_llm_temperature(), - ), + scope.llm_extra_headers(), + openai_compatible_temperature_for_provider(&active_llm, scope.llm_temperature()), ) } else if kind == "omni" { let active_omni = CredentialsVault::get_active_omni(); @@ -188,13 +260,14 @@ fn read_openai_provider_config(kind: &str) -> Result { }) } -async fn validate_llm_provider() -> Result<(), String> { +async fn validate_llm_provider(scope: &ProviderScope) -> Result<(), String> { let llm_thinking_enabled = PreferencesStore::new() .map_err(|e| e.to_string())? .get() .llm_thinking_enabled; - if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID { - let model = CredentialsVault::get(CredentialAccount::ArkModelId) + if scope.provider_type() == CODEX_OAUTH_PROVIDER_ID { + let model = scope + .get(CredentialAccount::ArkModelId) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()); @@ -218,9 +291,10 @@ async fn validate_llm_provider() -> Result<(), String> { .map_err(provider_llm_error_message); } - let config = read_openai_provider_config("llm")?; - let active_llm = CredentialsVault::get_active_llm(); - let model = CredentialsVault::get(CredentialAccount::ArkModelId) + let config = read_openai_provider_config("llm", scope)?; + let active_llm = scope.provider_type(); + let model = scope + .get(CredentialAccount::ArkModelId) .map_err(|e| e.to_string())? .filter(|s| !s.is_empty()) .ok_or_else(|| "llmModelMissing".to_string())?; @@ -276,8 +350,8 @@ async fn validate_omni_provider() -> Result<(), String> { .map_err(provider_llm_error_message) } -async fn validate_asr_provider() -> Result<(), String> { - let active_asr = CredentialsVault::get_active_asr(); +async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let active_asr = scope.provider_type(); if active_asr_is_keyless_for_validation(&active_asr) { return Ok(()); } @@ -285,53 +359,57 @@ async fn validate_asr_provider() -> Result<(), String> { if active_asr == crate::asr::bailian::PROVIDER_ID { // 统一百炼:按所选模型验证对应协议(endpoint 由前端按模型同步,各 validator // 读到的都是该协议的正确地址)。 - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .ok() .flatten() .unwrap_or_default(); let effective = crate::coordinator::resolve_effective_asr_provider(&active_asr, &model)?; if effective == crate::asr::qwen_realtime::PROVIDER_ID { - return validate_qwen3_realtime_asr_provider().await; + return validate_qwen3_realtime_asr_provider(scope).await; } if effective == crate::asr::dashscope_multimodal::PROVIDER_ID { - return validate_dashscope_multimodal_asr_provider().await; + return validate_dashscope_multimodal_asr_provider(scope).await; } - return validate_bailian_asr_provider().await; + return validate_bailian_asr_provider(scope).await; } if active_asr == crate::asr::qwen_realtime::PROVIDER_ID { - return validate_qwen3_realtime_asr_provider().await; + return validate_qwen3_realtime_asr_provider(scope).await; } if active_asr == crate::asr::mimo::PROVIDER_ID { - return validate_mimo_asr_provider().await; + return validate_mimo_asr_provider(scope).await; } if active_asr == crate::asr::dashscope_multimodal::PROVIDER_ID { - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .unwrap_or_default(); crate::coordinator::validate_dashscope_multimodal_model(&model)?; - return validate_dashscope_multimodal_asr_provider().await; + return validate_dashscope_multimodal_asr_provider(scope).await; } if active_asr == crate::asr::elevenlabs::PROVIDER_ID { - return validate_elevenlabs_asr_provider().await; + return validate_elevenlabs_asr_provider(scope).await; } if active_asr == crate::asr::xfyun::PROVIDER_ID { - return validate_xfyun_asr_provider().await; + return validate_xfyun_asr_provider(scope).await; } // StepFun 一入口双协议:`*-stream` 模型走实时 WS 验证,其余走批式 // /audio/transcriptions(与 build 侧 resolve_effective_asr_provider 同判据)。 if active_asr == "stepfun" || active_asr == crate::asr::stepfun_realtime::PROVIDER_ID { - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .unwrap_or_default(); if active_asr == crate::asr::stepfun_realtime::PROVIDER_ID || crate::coordinator::stepfun_model_is_stream(&model) { - return validate_stepfun_realtime_asr_provider().await; + return validate_stepfun_realtime_asr_provider(scope).await; } } - let config = read_openai_provider_config("asr")?; - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let config = read_openai_provider_config("asr", scope)?; + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .ok_or_else(|| "asrModelMissing".to_string())?; @@ -344,14 +422,16 @@ async fn validate_asr_provider() -> Result<(), String> { /// 讯飞 RTASR 验证:真连 + 500ms 静音 + 收尾。鉴权错误(10105 / 10110)在握手阶段 /// 即返回;纯静音会话服务端可能直接关闭且不返回任何 result(等价于「没说话」), /// 这类 `NoFinalResult` 不算验证失败 —— 握手成功已经证明 AppID/APIKey 有效。 -async fn validate_xfyun_asr_provider() -> Result<(), String> { - let app_id = CredentialsVault::get(CredentialAccount::XfyunAppId) +async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let app_id = scope + .get(CredentialAccount::XfyunAppId) .map_err(|e| e.to_string())? .unwrap_or_default(); if app_id.trim().is_empty() { return Err("讯飞 AppID 为空".to_string()); } - let api_key = CredentialsVault::get(CredentialAccount::XfyunApiKey) + let api_key = scope + .get(CredentialAccount::XfyunApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { @@ -376,17 +456,20 @@ async fn validate_xfyun_asr_provider() -> Result<(), String> { /// StepFun 实时 WS 验证:真连 + session.update + 500ms 静音 + 收尾。 /// 协议无 finish 事件,收尾走静音帧 + 宽限期(纯静音会话以空文本成功返回, /// 见 stepfun_realtime 模块注释),全程 ~2s。 -async fn validate_stepfun_realtime_asr_provider() -> Result<(), String> { - let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) +async fn validate_stepfun_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { return Err("API Key 为空".to_string()); } - let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::stepfun_realtime::DEFAULT_MODEL.to_string()); @@ -410,9 +493,10 @@ async fn validate_stepfun_realtime_asr_provider() -> Result<(), String> { .map_err(|e| e.to_string()) } -async fn validate_mimo_asr_provider() -> Result<(), String> { - let config = read_openai_provider_config("asr")?; - let model = CredentialsVault::get(CredentialAccount::AsrModel) +async fn validate_mimo_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let config = read_openai_provider_config("asr", scope)?; + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); @@ -427,18 +511,21 @@ async fn validate_mimo_asr_provider() -> Result<(), String> { .map_err(|e| e.to_string()) } -async fn validate_elevenlabs_asr_provider() -> Result<(), String> { - let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) +async fn validate_elevenlabs_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "API Key 为空".to_string())?; - let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) + let base_url = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string()); crate::endpoint_security::validate_http_endpoint(&base_url) .map_err(|_| "endpointInvalid".to_string())?; - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string()); @@ -473,10 +560,11 @@ const DASHSCOPE_ASR_VALIDATE_SAMPLE_URL: &str = const DASHSCOPE_ASR_VALIDATE_TIMEOUT_SECS: u64 = 120; const DASHSCOPE_ASR_VALIDATE_POLL_SECS: u64 = 60; -async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> { +async fn validate_dashscope_multimodal_asr_provider(scope: &ProviderScope) -> Result<(), String> { // 统一百炼复用配置中的区域/工作空间主机,并推导 multimodal 的 https 路径。 // 隐藏别名仍按原有完整 endpoint 读取。 - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string()); @@ -484,11 +572,13 @@ async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> { let protocol = crate::asr::dashscope_multimodal::protocol_for_model(&model) .unwrap_or(crate::asr::dashscope_multimodal::DashScopeBatchProtocol::Multimodal); let (api_key, base_url) = if crate::coordinator::unified_bailian_is_active() { - let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .ok_or_else(|| "API Key 为空".to_string())?; - let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); let endpoint_protocol = match protocol { @@ -502,7 +592,7 @@ async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> { let endpoint = crate::coordinator::derive_bailian_endpoint(&endpoint, endpoint_protocol)?; (api_key, endpoint) } else { - let config = read_openai_provider_config("asr")?; + let config = read_openai_provider_config("asr", scope)?; (config.api_key, config.base_url) }; if protocol == crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription { @@ -557,8 +647,9 @@ async fn send_dashscope_multimodal_validation( Ok(()) } -async fn validate_bailian_asr_provider() -> Result<(), String> { - let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) +async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { @@ -566,7 +657,8 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { } // 已知残留(issue #609 F-01 孪生 gap):Bailian endpoint 走 `wss://`,与 http/https-only 的 // validate_http_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。 - let stored_endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + let stored_endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string()); @@ -584,11 +676,13 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { if !crate::asr::bailian::endpoint_scheme_is_websocket(&endpoint) { return Err("bailianEndpointSchemeInvalid".to_string()); } - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string()); - let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId) + let vocabulary_id = scope + .get(CredentialAccount::AsrVocabularyId) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()); let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new( @@ -614,8 +708,9 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { .map_err(|e| e.to_string()) } -async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> { - let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) +async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> { + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { @@ -623,7 +718,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> { } // 统一百炼保留配置中的区域/工作空间主机,并切换到 Qwen Realtime 路径。 let endpoint = if crate::coordinator::unified_bailian_is_active() { - let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); crate::coordinator::derive_bailian_endpoint( @@ -631,7 +727,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> { crate::coordinator::BailianEndpointProtocol::QwenRealtime, )? } else { - CredentialsVault::get(CredentialAccount::AsrEndpoint) + scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_ENDPOINT.to_string()) @@ -639,7 +736,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> { if !crate::asr::qwen_realtime::endpoint_scheme_is_secure_websocket(&endpoint) { return Err("qwen3EndpointSchemeInvalid".to_string()); } - let model = CredentialsVault::get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_MODEL.to_string()); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index a26e4f6f7..7eca506ba 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -248,6 +248,15 @@ macro_rules! app_invoke_handler_desktop { commands::read_credential, commands::set_active_asr_provider, commands::set_active_llm_provider, + commands::list_channels, + commands::create_channel, + commands::rename_channel, + commands::set_channel_provider_type, + commands::delete_channel_if_blank, + commands::delete_channel, + commands::set_channel_enabled, + commands::reorder_channels, + commands::record_channel_test, commands::set_active_omni_provider, commands::get_qa_hotkey_label, commands::set_qa_hotkey, @@ -360,6 +369,15 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::read_credential, $crate::commands::set_active_asr_provider, $crate::commands::set_active_llm_provider, + $crate::commands::list_channels, + $crate::commands::create_channel, + $crate::commands::rename_channel, + $crate::commands::set_channel_provider_type, + $crate::commands::delete_channel_if_blank, + $crate::commands::delete_channel, + $crate::commands::set_channel_enabled, + $crate::commands::reorder_channels, + $crate::commands::record_channel_test, $crate::commands::set_active_omni_provider, $crate::commands::validate_provider_credentials, $crate::commands::list_provider_models, diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 2efe366a8..a99138147 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -245,9 +245,71 @@ impl std::fmt::Debug for MarketplaceGithubToken { } } +/// 渠道卡片的公共元信息 —— ASR / LLM 两侧共用同一套语义: +/// - `providerType` 是**协议路由 key**(deepseek / volcengine / bailian ...), +/// 必须独立于 map key:一个供应商可以有多张卡片(多把 key),此时 map key 是 +/// uuid,而 providerType 仍指向同一个厂商实现。 +/// `None` = v1 老数据,此时 map key 本身就是 providerType(见 `channel_provider_type`)。 +/// - `order` 越小越优先,启用列表的第一个即"当前使用"。 +/// - 关闭的渠道会被自动排到末尾(见 `commands::channels::toggle`)。 +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(non_snake_case)] +struct ChannelMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + providerType: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + order: Option, + /// 缺省 `true`:v1 老数据迁移后一律视为启用。 + #[serde(default = "channel_default_enabled", skip_serializing_if = "is_true")] + enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + lastTest: Option, +} + +/// 手写 `Default` 而不是 derive:`bool::default()` 是 `false`,而 `write_account` +/// 用 `map.entry(id).or_default()` 创建 entry —— derive 会让新写入的渠道一出生就是 +/// 禁用状态,`sync_active_channels` 直接忽略它,表现为"填了 key 却不生效"。 +impl Default for ChannelMeta { + fn default() -> Self { + Self { + providerType: None, + order: None, + enabled: channel_default_enabled(), + lastTest: None, + } + } +} + +fn channel_default_enabled() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value +} + +/// 「测试连通」的结果,持久化以便重启后仍能看到上次测试的延迟。 +/// `error` 同时承担 P0 的失败标红(测试失败)与 P2 的运行时失败标红。 +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(non_snake_case)] +struct ChannelTest { + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + latencyMs: Option, + /// Unix 秒。 + at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, +} + #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[allow(non_snake_case)] struct CredsAsrEntry { + #[serde(flatten)] + channel: ChannelMeta, + /// 用户给这张卡片取的名字;空则前端回落到 preset 显示名。 + #[serde(skip_serializing_if = "Option::is_none")] + displayName: Option, #[serde(skip_serializing_if = "Option::is_none")] apiKey: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -283,7 +345,20 @@ struct CredsAsrEntry { impl CredsAsrEntry { fn is_empty(&self) -> bool { - self.apiKey.as_deref().unwrap_or("").is_empty() + // 渠道卡片(providerType 已写入)永远不算空:用户可能刚点「添加渠道」、 + // 名字都取好了还没填 key,此时被 clean_credentials 的 retain 静默删掉 + // 就是"卡片自己消失了"。渠道只能由用户显式删除(或由 + // `delete_channel_if_blank` 回收一张什么都没填的草稿)。 + if self.channel.providerType.is_some() { + return false; + } + self.has_no_content() + } + + /// 除渠道元信息外,用户是否一个字都没填。草稿回收用。 + fn has_no_content(&self) -> bool { + self.displayName.as_deref().unwrap_or("").is_empty() + && self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() && self.appKey.as_deref().unwrap_or("").is_empty() @@ -301,6 +376,8 @@ impl CredsAsrEntry { #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[allow(non_snake_case)] struct CredsLlmEntry { + #[serde(flatten)] + channel: ChannelMeta, #[serde(skip_serializing_if = "Option::is_none")] displayName: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -317,6 +394,15 @@ struct CredsLlmEntry { impl CredsLlmEntry { fn is_empty(&self) -> bool { + // 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。 + if self.channel.providerType.is_some() { + return false; + } + self.has_no_content() + } + + /// 除渠道元信息外,用户是否一个字都没填。草稿回收用。 + fn has_no_content(&self) -> bool { self.displayName.as_deref().unwrap_or("").is_empty() && self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() @@ -330,6 +416,194 @@ impl CredsLlmEntry { } } +/// ASR / LLM 两种 entry 共享渠道元信息的读写口子,让迁移与排序逻辑只写一遍。 +trait HasChannelMeta { + fn meta(&self) -> &ChannelMeta; + fn meta_mut(&mut self) -> &mut ChannelMeta; + /// 用户是否往这张卡里填过东西 —— 迁移排序时用来避免把空卡片排到第一。 + fn is_blank(&self) -> bool; +} + +impl HasChannelMeta for CredsAsrEntry { + fn meta(&self) -> &ChannelMeta { + &self.channel + } + fn meta_mut(&mut self) -> &mut ChannelMeta { + &mut self.channel + } + fn is_blank(&self) -> bool { + self.has_no_content() + } +} + +impl HasChannelMeta for CredsLlmEntry { + fn meta(&self) -> &ChannelMeta { + &self.channel + } + fn meta_mut(&mut self) -> &mut ChannelMeta { + &mut self.channel + } + fn is_blank(&self) -> bool { + self.has_no_content() + } +} + +/// 渠道的协议路由 key。v1 老数据没有 `providerType`,此时 map key 本身就是厂商 id。 +/// +/// **这是渠道化最容易漏的一处**:`coordinator::resolve_effective_asr_provider` 和 +/// `commands/providers.rs` 里几十处 `== PROVIDER_ID` 的比较全都依赖它, +/// 拿成 channel id(uuid)会让整个 ASR 路由失效。 +fn channel_provider_type<'a, V: HasChannelMeta>(key: &'a str, entry: &'a V) -> &'a str { + entry.meta().providerType.as_deref().unwrap_or(key) +} + +/// 「当前使用」= 启用渠道里 order 最小的那个;order 相同则按 id 字母序,保证确定性。 +fn current_channel_id(map: &HashMap) -> Option { + map.iter() + .filter(|(_, entry)| entry.meta().enabled) + .min_by(|(left_key, left), (right_key, right)| { + let left_order = left.meta().order.unwrap_or(u32::MAX); + let right_order = right.meta().order.unwrap_or(u32::MAX); + left_order + .cmp(&right_order) + .then_with(|| left_key.as_str().cmp(right_key.as_str())) + }) + .map(|(key, _)| key.clone()) +} + +/// v1(一个 preset 一个槽)→ v2(渠道卡片)。 +/// +/// 幂等的两个支点: +/// 1. 迁移出来的渠道 **id 直接沿用原 preset id**,不生成 uuid —— 老用户的 map key +/// 一个字节都不变,重复执行结果完全一致(新建卡片才用 uuid)。 +/// 2. 已带 `providerType` 的 entry 一律跳过。 +/// +/// order 按「原 active 排第一,其余按 id 字母序」分配。用字母序而不是 preset 表顺序, +/// 是因为后端不知道前端 LLM_PRESETS / ASR_PRESETS 的排列,而字母序是确定的。 +fn migrate_channel_map(map: &mut HashMap, active: &str) -> bool { + if map.is_empty() + || map + .values() + .all(|entry| entry.meta().providerType.is_some()) + { + return false; + } + + let mut keys: Vec = map.keys().cloned().collect(); + // 排序优先级(false < true,所以"是"排前面): + // 1. 原来的 active —— 升级前用哪个,升级后还用哪个; + // 2. **填过凭据的** —— `active` 指向一个已不存在的 entry 是真实会发生的 + // (前端 prefs 与凭据库里的 active 是两份数据,历史上可能不同步)。这时若纯按 + // 字母序挑,很容易把一张空卡排到第一,用户升级后就看到"未配置",而他配好的 + // 那张其实还在列表下面躺着; + // 3. 字母序 —— 兜底,保证结果确定、迁移幂等。 + let is_blank: std::collections::HashMap<&String, bool> = map + .iter() + .map(|(key, entry)| (key, entry.is_blank())) + .collect(); + keys.sort_by(|left, right| { + let key_of = |key: &String| { + ( + key != active, + is_blank.get(key).copied().unwrap_or(true), + key.clone(), + ) + }; + key_of(left).cmp(&key_of(right)) + }); + + let mut changed = false; + for (index, key) in keys.iter().enumerate() { + let provider_type = key.clone(); + let Some(entry) = map.get_mut(key) else { + continue; + }; + let meta = entry.meta_mut(); + if meta.providerType.is_none() { + meta.providerType = Some(provider_type); + changed = true; + } + if meta.order.is_none() { + meta.order = Some(index as u32); + changed = true; + } + } + changed +} + +/// 渠道 schema 版本:1 = 一个 preset 一个槽;2 = 渠道卡片。 +const CHANNELS_SCHEMA_VERSION: u32 = 2; + +/// 就地把 v1 数据补成渠道卡片。返回是否有实际改动(调用方据此决定要不要落盘)。 +fn migrate_channels(root: &mut CredsRoot) -> bool { + let active_asr = root.active.asr.clone(); + let active_llm = root.active.llm.clone(); + let asr_changed = migrate_channel_map(&mut root.providers.asr, &active_asr); + let llm_changed = migrate_channel_map(&mut root.providers.llm, &active_llm); + + let seeded = if root.version < CHANNELS_SCHEMA_VERSION { + let seeded = seed_default_channels(root); + root.version = CHANNELS_SCHEMA_VERSION; + seeded + } else { + false + }; + + asr_changed || llm_changed || seeded +} + +/// 全新安装的平台预置。 +/// +/// 只有 Windows 需要:那里的默认 ASR 是本地 Foundry,无需任何 key、装上就能用 +/// (见 `creds_default_asr`)。渠道化后列表完全由用户添加,不预置的话 Windows 新用户 +/// 开箱会一个 ASR 都没有。mac / Linux 的默认是要填 key 的云端厂商,预置一张空卡片 +/// 没有意义,交给新手引导。 +/// +/// 靠 `version < 2` 把"全新安装"和"用户把渠道全删了"区分开:后者 version 已经是 2, +/// 不会被重新种回来。version 的落盘发生在下一次真实写入时(见 `load_credentials` +/// 关于不主动落盘的说明),在此之前每次冷启动都会在内存里重新预置,正是期望行为。 +fn seed_default_channels(root: &mut CredsRoot) -> bool { + #[cfg(target_os = "windows")] + { + if root.providers.asr.is_empty() { + let id = crate::asr::local::foundry::PROVIDER_ID.to_string(); + root.providers.asr.insert( + id.clone(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some(id.clone()), + order: Some(0), + enabled: true, + lastTest: None, + }, + ..Default::default() + }, + ); + root.active.asr = id; + return true; + } + } + let _ = root; + false +} + +/// 把 `active.asr` / `active.llm` 重算成"启用列表的第一个渠道 id"。 +/// +/// `active` 字段在渠道化后不再是用户直接选择的厂商,而是排序与开关的**派生结果**; +/// `lookup_account` / `write_account` 仍然读它,因此每次改动排序、开关或删除渠道后 +/// 都必须调用本函数,否则会出现"列表第一张是 A、实际请求打的是 B"。 +/// +/// 一个渠道都没启用时保持原值不动 —— 让 `lookup_account` 落到 `None`(未配置), +/// 而不是随机落到某个被关掉的渠道上。 +fn sync_active_channels(root: &mut CredsRoot) { + if let Some(id) = current_channel_id(&root.providers.asr) { + root.active.asr = id; + } + if let Some(id) = current_channel_id(&root.providers.llm) { + root.active.llm = id; + } +} + fn active_llm_extra_headers(root: &CredsRoot) -> HashMap { root.providers .llm @@ -1057,7 +1331,26 @@ fn load_android_credentials_into_cache_with( } } +/// 读凭据并就地补成渠道卡片。 +/// +/// 迁移**只在内存里做,不主动落盘**:`migrate_channels` 是幂等的(id 沿用原 preset +/// id,不生成 uuid),所以每次读的结果都一致;而启动时写 keyring 会在 macOS 上触发 +/// 「OpenLess 想使用钥匙串」的 ACL 弹窗。留给下一次真实写入(用户改配置)顺带固化。 fn load_credentials() -> CredsRoot { + let mut root = load_credentials_raw(); + migrate_channels(&mut root); + sync_active_channels(&mut root); + root +} + +fn load_credentials_for_update() -> Result { + let mut root = load_credentials_for_update_raw()?; + migrate_channels(&mut root); + sync_active_channels(&mut root); + Ok(root) +} + +fn load_credentials_raw() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } @@ -1100,7 +1393,7 @@ fn load_credentials() -> CredsRoot { } } -fn load_credentials_for_update() -> Result { +fn load_credentials_for_update_raw() -> Result { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } @@ -1138,7 +1431,11 @@ fn load_credentials_for_update() -> Result { } fn save_credentials(root: &CredsRoot) -> Result<()> { - let cleaned = clean_credentials(root); + let mut cleaned = clean_credentials(root); + // 落盘的 active 必须与"启用列表第一个"一致:删除或关闭当前渠道后若不重算, + // 磁盘上会留下指向已消失渠道的 active,下次冷启动直接读成"未配置"。 + sync_active_channels(&mut cleaned); + let cleaned = cleaned; #[cfg(target_os = "android")] { @@ -1421,6 +1718,188 @@ pub struct CredentialsSnapshot { pub omni_model: Option, } +/// 渠道所属的功能面。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChannelKind { + Asr, + Llm, +} + +impl ChannelKind { + pub fn parse(value: &str) -> Result { + match value { + "asr" => Ok(ChannelKind::Asr), + "llm" => Ok(ChannelKind::Llm), + other => anyhow::bail!("unknown channel kind: {other}"), + } + } +} + +/// 一张渠道卡片对前端的投影。凭据本身不在这里 —— 前端按 id 走 +/// `read_credential(account, provider = id)` 单独取,避免密钥随列表批量出栈。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelSummary { + pub id: String, + /// 用户取的名字;空字符串表示未命名,由前端回落到 preset 显示名。 + pub name: String, + pub provider_type: String, + pub enabled: bool, + pub order: u32, + pub last_test: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelTestSummary { + pub ok: bool, + pub latency_ms: Option, + pub at: i64, + pub error: Option, +} + +impl From<&ChannelTest> for ChannelTestSummary { + fn from(value: &ChannelTest) -> Self { + Self { + ok: value.ok, + latency_ms: value.latencyMs, + at: value.at, + error: value.error.clone(), + } + } +} + +fn channel_summaries( + map: &HashMap, + name_of: impl Fn(&V) -> String, +) -> Vec { + let mut list: Vec = map + .iter() + .map(|(id, entry)| { + let meta = entry.meta(); + ChannelSummary { + id: id.clone(), + name: name_of(entry), + provider_type: channel_provider_type(id, entry).to_string(), + enabled: meta.enabled, + order: meta.order.unwrap_or(u32::MAX), + last_test: meta.lastTest.as_ref().map(ChannelTestSummary::from), + } + }) + .collect(); + // 与 current_channel_id 同序:order 升序,同 order 按 id 字母序。 + list.sort_by(|left, right| { + left.order + .cmp(&right.order) + .then_with(|| left.id.cmp(&right.id)) + }); + list +} + +/// 生成一个未被占用的渠道 id:首选厂商 id 本身,冲突则 `-2` / `-3` 递增。 +/// +/// 刻意不用 uuid:第一张卡片的 id 就等于 preset id,与 `migrate_channel_map` +/// 的"沿用原 preset id"完全一致;credentials.json 排障时也一眼能看懂是哪家。 +fn allocate_channel_id(map: &HashMap, provider_type: &str) -> String { + if !map.contains_key(provider_type) { + return provider_type.to_string(); + } + for suffix in 2..u32::MAX { + let candidate = format!("{provider_type}-{suffix}"); + if !map.contains_key(&candidate) { + return candidate; + } + } + unreachable!("channel id space exhausted") +} + +/// 关闭渠道时把它排到末尾,重新打开时排到**启用组**末尾。 +/// +/// 重新打开不回原位是刻意的:回原位要额外持久化"关闭前的位置",而用户重开一张卡片 +/// 通常就是想试试它,落到启用组末尾最不打扰当前生效的渠道。 +fn reposition_after_toggle(map: &mut HashMap, id: &str) { + let Some(enabled) = map.get(id).map(|entry| entry.meta().enabled) else { + return; + }; + let target = if enabled { + // 启用组末尾 = 最大的启用 order + 1(不含自己)。 + map.iter() + .filter(|(key, entry)| key.as_str() != id && entry.meta().enabled) + .filter_map(|(_, entry)| entry.meta().order) + .max() + .map(|max| max.saturating_add(1)) + .unwrap_or(0) + } else { + // 整个列表末尾。 + map.iter() + .filter(|(key, _)| key.as_str() != id) + .filter_map(|(_, entry)| entry.meta().order) + .max() + .map(|max| max.saturating_add(1)) + .unwrap_or(0) + }; + if let Some(entry) = map.get_mut(id) { + entry.meta_mut().order = Some(target); + } + // 关掉的那张要沉到所有启用项之后:把启用项整体前移,重新压实 order。 + compact_orders(map); +} + +/// 重排 order 为 0..n 的连续整数,顺序为「启用项在前(按原 order),禁用项在后」。 +fn compact_orders(map: &mut HashMap) { + let mut ids: Vec = map.keys().cloned().collect(); + ids.sort_by(|left, right| { + let left_meta = map.get(left).map(|e| e.meta()); + let right_meta = map.get(right).map(|e| e.meta()); + let key_of = |meta: Option<&ChannelMeta>| { + let meta = meta.expect("id came from this map"); + // false < true:启用的排前面。 + (!meta.enabled, meta.order.unwrap_or(u32::MAX)) + }; + key_of(left_meta) + .cmp(&key_of(right_meta)) + .then_with(|| left.cmp(right)) + }); + for (index, id) in ids.iter().enumerate() { + if let Some(entry) = map.get_mut(id) { + entry.meta_mut().order = Some(index as u32); + } + } +} + +/// 新建渠道的 order:排到启用组末尾(禁用项始终在其后,由 compact_orders 保证)。 +fn next_order(map: &HashMap) -> u32 { + map.values() + .filter(|entry| entry.meta().enabled) + .filter_map(|entry| entry.meta().order) + .max() + .map(|max| max.saturating_add(1)) + .unwrap_or(0) +} + +/// 按前端给的 id 顺序重排;未提及的渠道保持在末尾(相对顺序不变)。 +fn apply_order(map: &mut HashMap, ordered_ids: &[String]) { + for (index, id) in ordered_ids.iter().enumerate() { + if let Some(entry) = map.get_mut(id) { + entry.meta_mut().order = Some(index as u32); + } + } + // 没被提到的排到末尾,避免与显式序号撞车。 + let tail_base = ordered_ids.len() as u32; + let unlisted: Vec = map + .keys() + .filter(|id| !ordered_ids.contains(id)) + .cloned() + .collect(); + for (offset, id) in unlisted.iter().enumerate() { + if let Some(entry) = map.get_mut(id) { + entry.meta_mut().order = Some(tail_base.saturating_add(offset as u32)); + } + } + // 拖拽后禁用项仍须沉底。 + compact_orders(map); +} + /// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; @@ -1558,9 +2037,22 @@ impl CredentialsVault { MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst); } + /// 当前 ASR 渠道的**厂商 id(providerType)**,不是渠道 id。 + /// + /// 渠道化后 `active.asr` 存的是渠道 id(多把 key 时是 uuid),但全代码库几十处 + /// `get_active_asr() == crate::asr::bailian::PROVIDER_ID` 式的比较、以及 + /// `coordinator::resolve_effective_asr_provider` 的协议路由,要的都是厂商 id。 + /// 因此这里做一次转换,让那些调用点保持零改动。 + /// 需要渠道 id 本身时用 `get_active_asr_channel_id`。 pub fn get_active_asr() -> String { let _guard = credentials_lock().lock(); - load_credentials().active.asr + let root = load_credentials(); + let id = root.active.asr.clone(); + root.providers + .asr + .get(&id) + .map(|entry| channel_provider_type(&id, entry).to_string()) + .unwrap_or(id) } pub fn set_active_asr_provider(id: &str) -> Result<()> { @@ -1577,9 +2069,339 @@ impl CredentialsVault { save_credentials(&root) } + /// 当前 LLM 渠道的**厂商 id(providerType)**。理由同 `get_active_asr`。 pub fn get_active_llm() -> String { let _guard = credentials_lock().lock(); - load_credentials().active.llm + let root = load_credentials(); + let id = root.active.llm.clone(); + root.providers + .llm + .get(&id) + .map(|entry| channel_provider_type(&id, entry).to_string()) + .unwrap_or(id) + } + + // ---- 渠道卡片管理 ---- + + pub fn list_channels(kind: ChannelKind) -> Vec { + let _guard = credentials_lock().lock(); + let root = load_credentials(); + match kind { + ChannelKind::Asr => channel_summaries(&root.providers.asr, |entry| { + entry.displayName.clone().unwrap_or_default() + }), + ChannelKind::Llm => channel_summaries(&root.providers.llm, |entry| { + entry.displayName.clone().unwrap_or_default() + }), + } + } + + /// 新建一张渠道卡片,返回分配到的 id。新卡片排在**启用组末尾**。 + pub fn create_channel(kind: ChannelKind, provider_type: &str, name: &str) -> Result { + let provider_type = provider_type.trim(); + if provider_type.is_empty() { + anyhow::bail!("provider type cannot be empty"); + } + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let name = name.trim(); + + let id = match kind { + ChannelKind::Asr => { + let id = allocate_channel_id(&root.providers.asr, provider_type); + let order = next_order(&root.providers.asr); + root.providers.asr.insert( + id.clone(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some(provider_type.to_string()), + order: Some(order), + enabled: true, + lastTest: None, + }, + displayName: (!name.is_empty()).then(|| name.to_string()), + ..Default::default() + }, + ); + id + } + ChannelKind::Llm => { + let id = allocate_channel_id(&root.providers.llm, provider_type); + let order = next_order(&root.providers.llm); + root.providers.llm.insert( + id.clone(), + CredsLlmEntry { + channel: ChannelMeta { + providerType: Some(provider_type.to_string()), + order: Some(order), + enabled: true, + lastTest: None, + }, + displayName: (!name.is_empty()).then(|| name.to_string()), + ..Default::default() + }, + ); + id + } + }; + + save_credentials(&root)?; + Ok(id) + } + + /// 改一张卡片的厂商。 + /// + /// 「添加渠道」被合并成单个弹窗后,用户是在**已经建好的草稿卡片上**换供应商的, + /// 所以这不是内部细节而是常规操作。旧厂商的凭据字段留着不动:不同厂商用不同的 + /// 凭据槽(volcengine.* / xfyun.* / asr.*),互不覆盖,换回去时原样还在。 + pub fn set_channel_provider_type( + kind: ChannelKind, + id: &str, + provider_type: &str, + ) -> Result<()> { + let provider_type = provider_type.trim(); + if provider_type.is_empty() { + anyhow::bail!("provider type cannot be empty"); + } + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let meta = match kind { + ChannelKind::Asr => root + .providers + .asr + .get_mut(id) + .map(|entry| entry.meta_mut()) + .with_context(|| format!("unknown ASR channel: {id}"))?, + ChannelKind::Llm => root + .providers + .llm + .get_mut(id) + .map(|entry| entry.meta_mut()) + .with_context(|| format!("unknown LLM channel: {id}"))?, + }; + meta.providerType = Some(provider_type.to_string()); + // 换了厂商,之前那次测试结果就不再代表这张卡片了。 + meta.lastTest = None; + save_credentials(&root) + } + + /// 回收一张「什么都没填」的草稿渠道,返回是否真的删了。 + /// + /// 单弹窗流程下,点开「添加渠道」就会先建一张草稿卡片(凭据必须按渠道 id 写入, + /// 没有 id 就没处可写)。用户什么都没填就关掉弹窗时用这个把草稿收走, + /// 免得列表里留下一张空卡片。填过任何一个字段就保留。 + pub fn delete_channel_if_blank(kind: ChannelKind, id: &str) -> Result { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let blank = match kind { + ChannelKind::Asr => root + .providers + .asr + .get(id) + .map(|entry| entry.has_no_content()) + .unwrap_or(false), + ChannelKind::Llm => root + .providers + .llm + .get(id) + .map(|entry| entry.has_no_content()) + .unwrap_or(false), + }; + if !blank { + return Ok(false); + } + match kind { + ChannelKind::Asr => { + root.providers.asr.remove(id); + compact_orders(&mut root.providers.asr); + } + ChannelKind::Llm => { + root.providers.llm.remove(id); + compact_orders(&mut root.providers.llm); + } + } + save_credentials(&root)?; + Ok(true) + } + + pub fn rename_channel(kind: ChannelKind, id: &str, name: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let name = name.trim(); + let name = (!name.is_empty()).then(|| name.to_string()); + match kind { + ChannelKind::Asr => { + let entry = root + .providers + .asr + .get_mut(id) + .with_context(|| format!("unknown ASR channel: {id}"))?; + entry.displayName = name; + } + ChannelKind::Llm => { + let entry = root + .providers + .llm + .get_mut(id) + .with_context(|| format!("unknown LLM channel: {id}"))?; + entry.displayName = name; + } + } + save_credentials(&root) + } + + pub fn delete_channel(kind: ChannelKind, id: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + match kind { + ChannelKind::Asr => { + root.providers + .asr + .remove(id) + .with_context(|| format!("unknown ASR channel: {id}"))?; + compact_orders(&mut root.providers.asr); + } + ChannelKind::Llm => { + root.providers + .llm + .remove(id) + .with_context(|| format!("unknown LLM channel: {id}"))?; + compact_orders(&mut root.providers.llm); + } + } + // save_credentials 内部会 sync_active_channels,把 active 顺延到下一张。 + save_credentials(&root) + } + + pub fn set_channel_enabled(kind: ChannelKind, id: &str, enabled: bool) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + match kind { + ChannelKind::Asr => { + let entry = root + .providers + .asr + .get_mut(id) + .with_context(|| format!("unknown ASR channel: {id}"))?; + entry.channel.enabled = enabled; + reposition_after_toggle(&mut root.providers.asr, id); + } + ChannelKind::Llm => { + let entry = root + .providers + .llm + .get_mut(id) + .with_context(|| format!("unknown LLM channel: {id}"))?; + entry.channel.enabled = enabled; + reposition_after_toggle(&mut root.providers.llm, id); + } + } + save_credentials(&root) + } + + /// 按前端给的完整 id 顺序重排。列表里没提到的渠道保持在末尾。 + pub fn reorder_channels(kind: ChannelKind, ordered_ids: &[String]) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + match kind { + ChannelKind::Asr => apply_order(&mut root.providers.asr, ordered_ids), + ChannelKind::Llm => apply_order(&mut root.providers.llm, ordered_ids), + } + save_credentials(&root) + } + + /// 记录一次「测试连通」的结果。 + pub fn record_channel_test( + kind: ChannelKind, + id: &str, + ok: bool, + latency_ms: Option, + at: i64, + error: Option, + ) -> Result<()> { + let test = ChannelTest { + ok, + latencyMs: latency_ms, + at, + error, + }; + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + match kind { + ChannelKind::Asr => { + let entry = root + .providers + .asr + .get_mut(id) + .with_context(|| format!("unknown ASR channel: {id}"))?; + entry.channel.lastTest = Some(test); + } + ChannelKind::Llm => { + let entry = root + .providers + .llm + .get_mut(id) + .with_context(|| format!("unknown LLM channel: {id}"))?; + entry.channel.lastTest = Some(test); + } + } + save_credentials(&root) + } + + /// 某张卡片的厂商 id。「测试连通」要按用户点的那张卡片决定协议,而不是当前生效的那张。 + pub fn get_channel_provider_type(kind: ChannelKind, id: &str) -> Option { + let _guard = credentials_lock().lock(); + let root = load_credentials(); + match kind { + ChannelKind::Asr => root + .providers + .asr + .get(id) + .map(|entry| channel_provider_type(id, entry).to_string()), + ChannelKind::Llm => root + .providers + .llm + .get(id) + .map(|entry| channel_provider_type(id, entry).to_string()), + } + } + + /// 指定 LLM 渠道的自定义请求头(测试连通用;不传渠道时用 `get_active_llm_extra_headers`)。 + pub fn get_llm_extra_headers_for_channel(id: &str) -> HashMap { + let _guard = credentials_lock().lock(); + let mut root = load_credentials(); + root.active.llm = id.to_string(); + active_llm_extra_headers(&root) + } + + /// 指定 LLM 渠道的采样温度。 + pub fn get_llm_temperature_for_channel(id: &str) -> Option { + let _guard = credentials_lock().lock(); + let mut root = load_credentials(); + root.active.llm = id.to_string(); + active_llm_temperature(&root) + } + + /// 按渠道 id 读 LLM 凭据(编辑非当前卡片时用)。 + /// + /// ASR 早就有 `get_for_asr_provider`;LLM 侧原本只能读"当前 active", + /// 渠道化后必须能读任意一张卡片。 + pub fn get_for_llm_provider(id: &str, account: CredentialAccount) -> Result> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials(); + root.active.llm = id.to_string(); + Ok(lookup_account(&root, account)) + } + + pub fn set_for_llm_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let active = root.active.llm.clone(); + root.active.llm = id.to_string(); + let value = (!value.is_empty()).then(|| value.to_string()); + write_account(&mut root, account, value); + root.active.llm = active; + save_credentials(&root) } pub fn get_active_omni() -> String { @@ -1734,6 +2556,7 @@ mod tests { }; use anyhow::anyhow; use parking_lot::Mutex; + use std::collections::HashMap; #[test] fn credential_payload_chunks_stay_under_windows_blob_limit() { @@ -2159,4 +2982,406 @@ mod tests { Some("0.7") ); } + + // ---- 渠道卡片(v1 → v2)---- + + fn v1_root_with_two_asr_providers() -> CredsRoot { + let mut root = CredsRoot::default(); + root.active.asr = "volcengine".into(); + root.providers.asr.insert( + "volcengine".into(), + CredsAsrEntry { + appKey: Some("vk".into()), + ..Default::default() + }, + ); + root.providers.asr.insert( + "groq".into(), + CredsAsrEntry { + apiKey: Some("gk".into()), + ..Default::default() + }, + ); + root + } + + #[test] + fn migration_keeps_preset_ids_as_channel_ids_and_puts_active_first() { + let mut root = v1_root_with_two_asr_providers(); + assert!(super::migrate_channels(&mut root)); + + // id 沿用原 preset id —— 老用户的 map key 一个字节都不变。 + let volcengine = root + .providers + .asr + .get("volcengine") + .expect("volcengine kept"); + let groq = root.providers.asr.get("groq").expect("groq kept"); + + assert_eq!( + volcengine.channel.providerType.as_deref(), + Some("volcengine") + ); + assert_eq!(groq.channel.providerType.as_deref(), Some("groq")); + // 原 active 排第一。 + assert_eq!(volcengine.channel.order, Some(0)); + assert_eq!(groq.channel.order, Some(1)); + // v1 老数据一律视为启用。 + assert!(volcengine.channel.enabled); + assert!(groq.channel.enabled); + } + + #[test] + fn migration_is_idempotent() { + let mut root = v1_root_with_two_asr_providers(); + assert!(super::migrate_channels(&mut root)); + let after_first = serde_json::to_string(&root).expect("encode"); + + // 第二次必须无改动(返回 false)且结果逐字节一致。 + assert!(!super::migrate_channels(&mut root)); + assert_eq!(serde_json::to_string(&root).expect("encode"), after_first); + } + + #[test] + fn migrated_credentials_still_resolve_through_lookup_account() { + let mut root = v1_root_with_two_asr_providers(); + super::migrate_channels(&mut root); + super::sync_active_channels(&mut root); + + // 迁移后凭据读取行为不变 —— 这是老用户升级不炸的底线。 + assert_eq!( + lookup_account(&root, CredentialAccount::VolcengineAppKey).as_deref(), + Some("vk") + ); + } + + #[test] + fn active_follows_order_and_enabled_not_user_choice() { + let mut root = v1_root_with_two_asr_providers(); + super::migrate_channels(&mut root); + + // 把 groq 拖到第一。 + root.providers.asr.get_mut("groq").unwrap().channel.order = Some(0); + root.providers + .asr + .get_mut("volcengine") + .unwrap() + .channel + .order = Some(1); + super::sync_active_channels(&mut root); + assert_eq!(root.active.asr, "groq"); + + // 关掉 groq 后,当前渠道顺延到下一个启用的。 + root.providers.asr.get_mut("groq").unwrap().channel.enabled = false; + super::sync_active_channels(&mut root); + assert_eq!(root.active.asr, "volcengine"); + } + + #[test] + fn every_channel_disabled_leaves_active_untouched_so_lookup_reports_unconfigured() { + let mut root = v1_root_with_two_asr_providers(); + super::migrate_channels(&mut root); + for entry in root.providers.asr.values_mut() { + entry.channel.enabled = false; + } + super::sync_active_channels(&mut root); + // 不去随机挑一个被关掉的渠道顶上。 + assert_eq!(root.active.asr, "volcengine"); + } + + /// `active` 指向一个**不存在的 entry** 是真实会发生的:前端 prefs 里的 + /// `activeAsrProvider` 与凭据库里的 `active.asr` 是两份数据,历史上可能不同步。 + /// 此时迁移只能退而求其次选一张,但**绝不允许动任何凭据** —— 用户的 key 必须原样 + /// 留在各自的 entry 里,用户把想用的那张拖回第一位就能恢复。 + #[test] + fn migration_never_touches_credentials_even_when_active_points_at_a_missing_entry() { + let mut root = CredsRoot::default(); + root.active.asr = "stepfun".into(); // 凭据库里并没有这个 entry + root.providers.asr.insert( + "volcengine".into(), + CredsAsrEntry { + appKey: Some("vk".into()), + accessKey: Some("ak".into()), + ..Default::default() + }, + ); + root.providers.asr.insert( + "groq".into(), + CredsAsrEntry { + apiKey: Some("gk".into()), + ..Default::default() + }, + ); + + super::migrate_channels(&mut root); + super::sync_active_channels(&mut root); + + // 迁移只写 providerType / order,凭据一个字节都不动。 + assert_eq!( + root.providers.asr.get("volcengine").unwrap().appKey.as_deref(), + Some("vk") + ); + assert_eq!( + root.providers.asr.get("volcengine").unwrap().accessKey.as_deref(), + Some("ak") + ); + assert_eq!( + root.providers.asr.get("groq").unwrap().apiKey.as_deref(), + Some("gk") + ); + // 两张卡片都还在,用户可以自己拖回想要的那张。 + assert_eq!(root.providers.asr.len(), 2); + // active 退到一个真实存在的渠道上,而不是继续指向空气。 + assert!(root.providers.asr.contains_key(&root.active.asr)); + } + + #[test] + fn migration_prefers_a_configured_channel_over_alphabetical_order() { + // active 指向一个不存在的 entry;`aaa-empty` 字母序更靠前但一个字都没填, + // `volcengine` 才是用户真正配好的那张。纯字母序会让用户升级后看到"未配置"。 + let mut root = CredsRoot::default(); + root.active.asr = "stepfun".into(); + root.providers.asr.insert( + "aaa-empty".into(), + CredsAsrEntry { + ..Default::default() + }, + ); + root.providers.asr.insert( + "volcengine".into(), + CredsAsrEntry { + appKey: Some("vk".into()), + accessKey: Some("ak".into()), + resourceId: Some("rid".into()), + ..Default::default() + }, + ); + + super::migrate_channels(&mut root); + super::sync_active_channels(&mut root); + + assert_eq!(root.active.asr, "volcengine"); + // 凭据确实能通过正常读取路径拿到 —— 也就是 UI 上会显示"已配置"。 + assert_eq!( + lookup_account(&root, CredentialAccount::VolcengineAppKey).as_deref(), + Some("vk") + ); + } + + #[test] + fn freshly_added_channel_survives_clean_credentials() { + let mut root = CredsRoot::default(); + // 刚点「添加渠道」、名字取好了但还没填 key。 + root.providers.asr.insert( + "chan-uuid".into(), + CredsAsrEntry { + channel: super::ChannelMeta { + providerType: Some("groq".into()), + order: Some(0), + enabled: true, + lastTest: None, + }, + displayName: Some("Groq-备用".into()), + ..Default::default() + }, + ); + + let cleaned = super::clean_credentials(&root); + assert!( + cleaned.providers.asr.contains_key("chan-uuid"), + "空 key 的新建渠道被 clean_credentials 静默删掉了" + ); + } + + #[test] + fn v1_payload_without_channel_fields_still_deserializes() { + // flatten 的 ChannelMeta 不能破坏老 payload 的反序列化。 + let v1 = r#"{ + "version": 1, + "active": { "asr": "volcengine", "llm": "ark" }, + "providers": { + "asr": { "volcengine": { "appKey": "vk", "accessKey": "ak" } }, + "llm": { "ark": { "apiKey": "sk", "model": "deepseek-v3-2" } } + } + }"#; + let root: CredsRoot = serde_json::from_str(v1).expect("v1 payload must still parse"); + assert_eq!( + root.providers + .asr + .get("volcengine") + .unwrap() + .appKey + .as_deref(), + Some("vk") + ); + // 缺省即启用,且尚未渠道化。 + let entry = root.providers.asr.get("volcengine").unwrap(); + assert!(entry.channel.enabled); + assert_eq!(entry.channel.providerType, None); + // 未迁移时 providerType 回落到 map key。 + assert_eq!( + super::channel_provider_type("volcengine", entry), + "volcengine" + ); + } + + // ---- 排序 / 开关 ---- + + /// 造一组 ASR 渠道:`(id, order, enabled)`。 + fn channels(spec: &[(&str, u32, bool)]) -> HashMap { + spec.iter() + .map(|(id, order, enabled)| { + ( + (*id).to_string(), + CredsAsrEntry { + channel: super::ChannelMeta { + providerType: Some((*id).to_string()), + order: Some(*order), + enabled: *enabled, + lastTest: None, + }, + ..Default::default() + }, + ) + }) + .collect() + } + + /// 按 order 升序取出 `(id, enabled)`,用来断言列表的可见顺序。 + fn ordered(map: &HashMap) -> Vec<(String, bool)> { + let mut list: Vec<_> = map + .iter() + .map(|(id, entry)| { + ( + id.clone(), + entry.channel.enabled, + entry.channel.order.unwrap_or(u32::MAX), + ) + }) + .collect(); + list.sort_by(|left, right| left.2.cmp(&right.2).then_with(|| left.0.cmp(&right.0))); + list.into_iter() + .map(|(id, enabled, _)| (id, enabled)) + .collect() + } + + #[test] + fn disabling_a_channel_sinks_it_below_every_enabled_one() { + let mut map = channels(&[("a", 0, true), ("b", 1, true), ("c", 2, true)]); + map.get_mut("a").unwrap().channel.enabled = false; + super::reposition_after_toggle(&mut map, "a"); + + assert_eq!( + ordered(&map), + vec![("b".into(), true), ("c".into(), true), ("a".into(), false),] + ); + } + + #[test] + fn re_enabling_a_channel_lands_at_the_end_of_the_enabled_group() { + let mut map = channels(&[("a", 0, true), ("b", 1, true), ("c", 2, false)]); + map.get_mut("c").unwrap().channel.enabled = true; + super::reposition_after_toggle(&mut map, "c"); + + // 不回原位、也不抢第一 —— 落到启用组末尾,不打扰当前生效的 a。 + assert_eq!( + ordered(&map), + vec![("a".into(), true), ("b".into(), true), ("c".into(), true)] + ); + } + + #[test] + fn compact_orders_keeps_disabled_channels_at_the_bottom() { + let mut map = channels(&[("a", 5, false), ("b", 9, true), ("c", 1, true)]); + super::compact_orders(&mut map); + + assert_eq!( + ordered(&map), + vec![("c".into(), true), ("b".into(), true), ("a".into(), false),] + ); + // order 压实成 0..n,避免反复拖拽后数值发散。 + let mut orders: Vec = map + .values() + .map(|entry| entry.channel.order.unwrap()) + .collect(); + orders.sort_unstable(); + assert_eq!(orders, vec![0, 1, 2]); + } + + #[test] + fn reorder_puts_the_dragged_channel_first_and_drives_active() { + let mut root = CredsRoot::default(); + root.providers.asr = channels(&[("a", 0, true), ("b", 1, true)]); + super::apply_order(&mut root.providers.asr, &["b".to_string(), "a".to_string()]); + super::sync_active_channels(&mut root); + + assert_eq!(ordered(&root.providers.asr)[0].0, "b"); + assert_eq!(root.active.asr, "b"); + } + + #[test] + fn reorder_tolerates_ids_the_frontend_did_not_mention() { + let mut map = channels(&[("a", 0, true), ("b", 1, true), ("c", 2, true)]); + // 前端只发了两个 id(比如 c 是刚被另一个窗口加进来的)。 + super::apply_order(&mut map, &["c".to_string(), "a".to_string()]); + + let order = ordered(&map); + assert_eq!(order[0].0, "c"); + assert_eq!(order[1].0, "a"); + // 没提到的 b 落到末尾而不是消失或撞车。 + assert_eq!(order[2].0, "b"); + } + + #[test] + fn new_channel_id_falls_back_to_numbered_suffix_for_same_provider() { + let mut map = channels(&[("deepseek", 0, true)]); + let second = super::allocate_channel_id(&map, "deepseek"); + assert_eq!(second, "deepseek-2"); + + map.insert(second, Default::default()); + assert_eq!(super::allocate_channel_id(&map, "deepseek"), "deepseek-3"); + // 不同厂商仍拿到干净的 id。 + assert_eq!(super::allocate_channel_id(&map, "groq"), "groq"); + } + + #[test] + fn new_channel_lands_at_the_end_of_the_enabled_group() { + // 禁用项的 order 更大,但新卡片要排在启用组末尾,而不是整个列表末尾。 + let map = channels(&[("a", 0, true), ("b", 1, true), ("c", 2, false)]); + assert_eq!(super::next_order(&map), 2); + } + + #[test] + fn provider_type_is_independent_of_channel_id_for_multi_key_setups() { + // 同一家两把 key:map key 是 uuid,providerType 都指向 deepseek。 + let mut root = CredsRoot::default(); + for (id, order) in [("uuid-a", 0u32), ("uuid-b", 1)] { + root.providers.llm.insert( + id.into(), + super::CredsLlmEntry { + channel: super::ChannelMeta { + providerType: Some("deepseek".into()), + order: Some(order), + enabled: true, + lastTest: None, + }, + apiKey: Some(format!("sk-{id}")), + ..Default::default() + }, + ); + } + super::sync_active_channels(&mut root); + assert_eq!(root.active.llm, "uuid-a"); + + let entry = root.providers.llm.get(&root.active.llm).unwrap(); + // 协议路由拿到的必须是厂商 id,不是 uuid。 + assert_eq!( + super::channel_provider_type(&root.active.llm, entry), + "deepseek" + ); + assert_eq!( + lookup_account(&root, CredentialAccount::ArkApiKey).as_deref(), + Some("sk-uuid-a") + ); + } } diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 2e2168b05..0ad263344 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -16,7 +16,7 @@ import { import { getHotkeyTriggerLabel } from '../lib/hotkey'; import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { ProvidersSection } from '../pages/settings/ProvidersSection'; +import { ProvidersSection } from '../pages/settings/ChannelList'; interface OnboardingProps { onComplete: () => void; @@ -205,9 +205,9 @@ function AndroidStepContent({ step }: { step: AndroidStepId }) { return ; } if (step === 'asr') { - return ; + return ; } - return ; + return ; } function AndroidMicrophoneStep() { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f7c71f84a..fa08f101c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -864,7 +864,35 @@ export const en: typeof zhCN = { startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', }, + channels: { + verify: 'Verify', + verifyHint: 'Makes one real API call to check this channel works right now', + errTimeout: 'timeout', + errNetwork: 'network', + errEndpoint: 'endpoint', + errGeneric: 'failed', + dragHint: 'Drag to change priority', + orderHint: 'Drag to reorder — the topmost one is used first. Disabled channels sink to the bottom.', + empty: 'No channels yet. Use "Add channel" below to create one.', + add: 'Add channel', + edit: 'Edit', + createTitle: 'Add channel', + editTitle: 'Edit channel', + providerLabel: 'Provider', + nameLabel: 'Name', + namePlaceholder: 'e.g. SiliconFlow — main key', + create: 'Create', + delete: 'Delete channel', + deleteConfirm: 'Deleting also clears the keys stored for this channel.', + confirmDelete: 'Delete', + justNow: 'just now', + minutesAgo: '{{count}}m ago', + hoursAgo: '{{count}}h ago', + daysAgo: '{{count}}d ago', + localEngineModelHint: 'Local engine models are downloaded and switched under Advanced → Local models.', + }, providers: { + localEngineNoCredentials: 'Local engines need no API key or endpoint.', localModelLabel: 'Local model', localModelEmpty: 'No local model downloaded yet', appleSpeechLocalNote: 'Apple Speech uses the system built-in engine — no model selection needed.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 86a86147c..c80753892 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -866,7 +866,35 @@ export const ja: typeof zhCN = { startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', }, + channels: { + verify: '検証', + verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します', + errTimeout: 'タイムアウト', + errNetwork: 'ネットワーク', + errEndpoint: 'エンドポイント', + errGeneric: '失敗', + dragHint: 'ドラッグで優先順位を変更', + orderHint: 'ドラッグで並べ替え。一番上が優先で使われます。オフにしたチャネルは末尾に移動します。', + empty: 'チャネルがまだありません。下の「チャネルを追加」から作成してください。', + add: 'チャネルを追加', + edit: '編集', + createTitle: 'チャネルを追加', + editTitle: 'チャネルを編集', + providerLabel: 'プロバイダー', + nameLabel: '名前', + namePlaceholder: '例:SiliconFlow — メインキー', + create: '作成', + delete: 'チャネルを削除', + deleteConfirm: '削除するとこのチャネルに保存された鍵も消去されます。', + confirmDelete: '削除する', + justNow: 'たった今', + minutesAgo: '{{count}}分前', + hoursAgo: '{{count}}時間前', + daysAgo: '{{count}}日前', + localEngineModelHint: 'ローカルエンジンのモデルは「詳細 → ローカルモデル」でダウンロード・切り替えします。', + }, providers: { + localEngineNoCredentials: 'ローカルエンジンに API キーやエンドポイントは不要です。', localModelLabel: 'ローカルモデル', localModelEmpty: 'ローカルモデル未ダウンロード', appleSpeechLocalNote: 'Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e51111ffc..35837887e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -866,7 +866,35 @@ export const ko: typeof zhCN = { startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', }, + channels: { + verify: '검증', + verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다', + errTimeout: '시간 초과', + errNetwork: '네트워크', + errEndpoint: '주소', + errGeneric: '실패', + dragHint: '드래그해서 우선순위 변경', + orderHint: '드래그해서 순서를 바꾸세요. 맨 위가 먼저 사용됩니다. 끈 채널은 맨 아래로 내려갑니다.', + empty: '아직 채널이 없습니다. 아래 "채널 추가"로 만들어 보세요.', + add: '채널 추가', + edit: '편집', + createTitle: '채널 추가', + editTitle: '채널 편집', + providerLabel: '공급자', + nameLabel: '이름', + namePlaceholder: '예: SiliconFlow — 메인 키', + create: '만들기', + delete: '채널 삭제', + deleteConfirm: '삭제하면 이 채널에 저장된 키도 함께 지워집니다.', + confirmDelete: '삭제', + justNow: '방금', + minutesAgo: '{{count}}분 전', + hoursAgo: '{{count}}시간 전', + daysAgo: '{{count}}일 전', + localEngineModelHint: '로컬 엔진 모델은 고급 → 로컬 모델에서 내려받고 전환합니다.', + }, providers: { + localEngineNoCredentials: '로컬 엔진은 API 키나 엔드포인트가 필요 없습니다.', localModelLabel: '로컬 모델', localModelEmpty: '아직 다운로드된 로컬 모델이 없습니다', appleSpeechLocalNote: 'Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index be50913ca..32a8f9825 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -862,7 +862,35 @@ export const zhCN = { startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', }, + channels: { + verify: '验证', + verifyHint: '点一下真实调用一次接口,确认这张卡现在能用', + errTimeout: '超时', + errNetwork: '网络', + errEndpoint: '地址', + errGeneric: '失败', + dragHint: '按住拖动可调整优先级', + orderHint: '拖动排序,最上面的优先使用;关掉的渠道会自动排到末尾。', + empty: '还没有渠道。点下面的「添加渠道」新建一个。', + add: '添加渠道', + edit: '编辑', + createTitle: '添加渠道', + editTitle: '编辑渠道', + providerLabel: '供应商', + nameLabel: '名称', + namePlaceholder: '例如:硅基流动-主号', + create: '创建', + delete: '删除渠道', + deleteConfirm: '删除后该渠道保存的密钥也会一并清除。', + confirmDelete: '确认删除', + justNow: '刚刚', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + daysAgo: '{{count}} 天前', + localEngineModelHint: '本地引擎的模型下载与切换在「高级 → 本地模型」里。', + }, providers: { + localEngineNoCredentials: '本地引擎无需 API Key 与地址。', localModelLabel: '本地模型', localModelEmpty: '尚未下载本地模型', appleSpeechLocalNote: 'Apple 语音识别使用系统内置引擎,无需选择模型。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 1294d9a5f..d620ad433 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -864,7 +864,35 @@ export const zhTW: typeof zhCN = { startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', }, + channels: { + verify: '驗證', + verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用', + errTimeout: '逾時', + errNetwork: '網路', + errEndpoint: '網址', + errGeneric: '失敗', + dragHint: '按住拖曳可調整優先順序', + orderHint: '拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。', + empty: '還沒有渠道。點下面的「新增渠道」建立一個。', + add: '新增渠道', + edit: '編輯', + createTitle: '新增渠道', + editTitle: '編輯渠道', + providerLabel: '供應商', + nameLabel: '名稱', + namePlaceholder: '例如:矽基流動-主帳號', + create: '建立', + delete: '刪除渠道', + deleteConfirm: '刪除後該渠道儲存的金鑰也會一併清除。', + confirmDelete: '確認刪除', + justNow: '剛剛', + minutesAgo: '{{count}} 分鐘前', + hoursAgo: '{{count}} 小時前', + daysAgo: '{{count}} 天前', + localEngineModelHint: '本機引擎的模型下載與切換在「進階 → 本機模型」裡。', + }, providers: { + localEngineNoCredentials: '本機引擎不需要 API Key 與網址。', localModelLabel: '本地模型', localModelEmpty: '尚未下載本地模型', appleSpeechLocalNote: 'Apple 語音辨識使用系統內建引擎,無需選擇模型。', diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 25e7e3c28..d5a6e9695 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,35 +1,35 @@ -import type { CredentialsStatus } from "../types" -import { invokeOrMock } from "./shared" -import { mockCredentialsStatus } from "./mock-data" - -export interface ProviderCheckResult { - ok: boolean -} - -export interface ProviderModelsResult { - models: string[] -} - -export function getCredentials(): Promise { - return invokeOrMock( - "get_credentials", - undefined, - () => mockCredentialsStatus, - ) -} - -export function setCredential(account: string, value: string, provider?: string): Promise { - return invokeOrMock("set_credential", { account, value, provider }, () => undefined) -} - -export function setActiveAsrProvider(provider: string): Promise { - return invokeOrMock( - "set_active_asr_provider", - { provider }, - () => undefined, - ) -} - +import type { CredentialsStatus } from "../types" +import { invokeOrMock } from "./shared" +import { mockCredentialsStatus } from "./mock-data" + +export interface ProviderCheckResult { + ok: boolean +} + +export interface ProviderModelsResult { + models: string[] +} + +export function getCredentials(): Promise { + return invokeOrMock( + "get_credentials", + undefined, + () => mockCredentialsStatus, + ) +} + +export function setCredential(account: string, value: string, provider?: string): Promise { + return invokeOrMock("set_credential", { account, value, provider }, () => undefined) +} + +export function setActiveAsrProvider(provider: string): Promise { + return invokeOrMock( + "set_active_asr_provider", + { provider }, + () => undefined, + ) +} + export function setActiveLlmProvider(provider: string): Promise { return invokeOrMock( "set_active_llm_provider", @@ -46,29 +46,32 @@ export function setActiveOmniProvider(provider: string): Promise { ) } -export function readCredential(account: string, provider?: string): Promise { - return invokeOrMock( - "read_credential", - { account, provider }, - () => null, - ) -} - +export function readCredential(account: string, provider?: string): Promise { + return invokeOrMock( + "read_credential", + { account, provider }, + () => null, + ) +} + +/** `channelId` 省略时测当前生效的渠道;卡片上的「测试连通」会带上那张卡片的 id。 */ export function validateProviderCredentials( kind: "llm" | "asr" | "omni", + channelId?: string, ): Promise { - return invokeOrMock("validate_provider_credentials", { kind }, () => ({ - ok: true, - })) -} - + return invokeOrMock("validate_provider_credentials", { kind, channelId }, () => ({ + ok: true, + })) +} + export function listProviderModels( kind: "llm" | "asr" | "omni", + channelId?: string, ): Promise { - return invokeOrMock("list_provider_models", { kind }, () => ({ - models: - kind === "llm" - ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] - : ["whisper-1"], - })) -} + return invokeOrMock("list_provider_models", { kind, channelId }, () => ({ + models: + kind === "llm" + ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] + : ["whisper-1"], + })) +} diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts new file mode 100644 index 000000000..14ca64841 --- /dev/null +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -0,0 +1,176 @@ +// 渠道卡片的 IPC 封装。 +// +// 一张卡片 = 一份可命名、可排序、可开关的供应商配置;同一家厂商可以有多张卡片 +// (多把 key)。列表里**第一个启用的**就是当前生效的渠道 —— 后端不另存"当前选中", +// 排序即优先级(见 docs/provider-channels-plan.md)。 +// +// 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。 + +import { invokeOrMock } from "./shared" + +export type ChannelKind = "llm" | "asr" + +export interface ChannelTestResult { + ok: boolean + latencyMs: number | null + /** Unix 秒(后端时钟,前端不要自己生成)。 */ + at: number + error: string | null +} + +export interface Channel { + id: string + /** 用户取的名字;空串表示未命名,由 UI 回落到 preset 显示名。 */ + name: string + /** 厂商 id —— 决定协议与表单形状,与 id 相互独立。 */ + providerType: string + enabled: boolean + order: number + lastTest: ChannelTestResult | null +} + +// 浏览器(非 Tauri)下的样例数据,让 `npm run dev` 能预览列表的四种状态: +// 生效中 / 备用 / 测试失败标红 / 已关闭沉底。 +const mockChannels: Record = { + llm: [ + { + id: "siliconflow", + name: "硅基流动-主号", + providerType: "siliconflow", + enabled: true, + order: 0, + lastTest: { ok: true, latencyMs: 284, at: Math.floor(Date.now() / 1000) - 90, error: null }, + }, + { + id: "ark", + name: "", + providerType: "ark", + enabled: true, + order: 1, + lastTest: null, + }, + { + id: "openai", + name: "OpenAI-备用", + providerType: "openai", + enabled: false, + order: 2, + lastTest: { ok: false, latencyMs: null, at: Math.floor(Date.now() / 1000) - 3600, error: "providerHttpStatus:401" }, + }, + ], + asr: [ + { + id: "volcengine", + name: "", + providerType: "volcengine", + enabled: true, + order: 0, + lastTest: { ok: true, latencyMs: 143, at: Math.floor(Date.now() / 1000) - 20, error: null }, + }, + { + id: "groq", + name: "Groq-白嫖号", + providerType: "groq", + enabled: true, + order: 1, + lastTest: null, + }, + ], +} + +export function listChannels(kind: ChannelKind): Promise { + return invokeOrMock("list_channels", { kind }, () => mockChannels[kind]) +} + +/** 返回后端分配的渠道 id。 */ +export function createChannel( + kind: ChannelKind, + providerType: string, + name: string, +): Promise { + return invokeOrMock( + "create_channel", + { kind, providerType, name }, + () => providerType, + ) +} + +/** 在已建好的草稿卡片上换供应商(单弹窗添加流程的常规操作)。 */ +export function setChannelProviderType( + kind: ChannelKind, + id: string, + providerType: string, +): Promise { + return invokeOrMock( + "set_channel_provider_type", + { kind, id, providerType }, + () => undefined, + ) +} + +/** 关闭添加弹窗时回收没填任何东西的草稿;返回是否真的删了。 */ +export function deleteChannelIfBlank( + kind: ChannelKind, + id: string, +): Promise { + return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true) +} + +export function renameChannel( + kind: ChannelKind, + id: string, + name: string, +): Promise { + return invokeOrMock("rename_channel", { kind, id, name }, () => undefined) +} + +export function deleteChannel(kind: ChannelKind, id: string): Promise { + return invokeOrMock("delete_channel", { kind, id }, () => undefined) +} + +export function setChannelEnabled( + kind: ChannelKind, + id: string, + enabled: boolean, +): Promise { + return invokeOrMock( + "set_channel_enabled", + { kind, id, enabled }, + () => undefined, + ) +} + +/** ids 是拖拽后的完整顺序;后端会把未提及的渠道排到末尾。 */ +export function reorderChannels( + kind: ChannelKind, + ids: string[], +): Promise { + return invokeOrMock("reorder_channels", { kind, ids }, () => { + // mock 也要真的重排:否则浏览器预览里松手后顺序被 listChannels 拉回原样, + // 看着就像"拖拽坏了",而真机是好的。 + const list = mockChannels[kind] + const ordered = ids + .map(id => list.find(c => c.id === id)) + .filter((c): c is Channel => Boolean(c)) + const rest = list.filter(c => !ids.includes(c.id)) + mockChannels[kind] = [...ordered, ...rest].map((c, index) => ({ + ...c, + order: index, + })) + return undefined + }) +} + +export function recordChannelTest( + kind: ChannelKind, + id: string, + ok: boolean, + latencyMs: number | null, + error: string | null, +): Promise { + return invokeOrMock( + "record_channel_test", + { kind, id, ok, latencyMs, error }, + () => undefined, + ) +} diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 8b0f13dd1..9ddb64b44 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -33,6 +33,20 @@ export { listProviderModels, } from "./asr-credentials" +// channels(渠道卡片) +export type { Channel, ChannelKind, ChannelTestResult } from "./channels" +export { + listChannels, + createChannel, + setChannelProviderType, + deleteChannelIfBlank, + renameChannel, + deleteChannel, + setChannelEnabled, + reorderChannels, + recordChannelTest, +} from "./channels" + // history export { listHistory, diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx new file mode 100644 index 000000000..3c2d05eae --- /dev/null +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -0,0 +1,805 @@ +// 渠道卡片列表 —— LLM 润色与 ASR 语音转写共用同一套交互。 +// +// 心智只有一条:**排序即优先级,列表里第一个启用的就是当前生效的渠道**。 +// 开关关掉的渠道自动沉到列表末尾;后端不另存"当前选中",避免"列表第一张是 A、 +// 实际请求打的是 B"这种两处真相。详见 docs/provider-channels-plan.md。 +// +// 卡片解决的两件事:同一家厂商可以存多把 key;key 之间切换只是拖一下顺序, +// 而不是把旧 key 覆盖掉。 + +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; +import { Modal } from '../../components/ui/Modal'; +import { SelectLite } from '../../components/ui/SelectLite'; +import { detectOS } from '../../components/WindowChrome'; +import { + createChannel, + deleteChannel, + deleteChannelIfBlank, + listChannels, + readCredential, + recordChannelTest, + renameChannel, + reorderChannels, + setChannelEnabled, + setChannelProviderType, + validateProviderCredentials, + type Channel, +} from '../../lib/ipc'; +import { emitSaved } from '../../lib/savedEvent'; +import { useMobileLayout } from '../../lib/useMobileLayout'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { + ChannelCredentialFields, + LLM_PRESETS, + LOCAL_ASR_PROVIDER_IDS, + OmniChannelSection, +} from './ProvidersSection'; +import { ASR_PRESETS, inputStyle, SectionTitle, Toggle } from './shared'; + +type ChannelKind = 'llm' | 'asr'; + +interface PresetOption { + id: string; + nameKey: string; +} + +/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的 + * 固定卡片,而是和云端厂商一样由用户添加,只是编辑时没有 key / 地址字段。 */ +function presetsFor(kind: ChannelKind, os: string): PresetOption[] { + if (kind === 'llm') { + return LLM_PRESETS.map(p => ({ id: p.id, nameKey: p.nameKey })); + } + return ASR_PRESETS.filter(p => { + // Apple 语音是 macOS 专有。 + if (p.id === 'apple-speech') return os === 'mac'; + // 百炼的两个旧 id 是历史别名,统一入口是 `bailian`,不再让新卡片选到。 + if (p.id === 'bailian-qwen3-realtime' || p.id === 'bailian-fun-asr-flash') return false; + return true; + }).map(p => ({ id: p.id, nameKey: p.nameKey })); +} + +function presetLabel( + kind: ChannelKind, + providerType: string, + t: ReturnType['t'], +): string { + const list: readonly { id: string; nameKey: string }[] = + kind === 'llm' ? LLM_PRESETS : ASR_PRESETS; + const preset = list.find(p => p.id === providerType); + return preset + ? t(`settings.providers.presets.${preset.nameKey}`) + : providerType; +} + +/** 卡片上模型那一行读的凭据账户 —— 与 ChannelCredentialFields 里保持一致。 */ +function modelAccountFor(kind: ChannelKind): string { + return kind === 'llm' ? 'ark.model_id' : 'asr.model'; +} + +/** + * 把后端的错误串压成按钮上放得下的短标签,且要**能指导行动**: + * 401 是 key 不对、429 是被限流等会儿再说、超时是网络——用户看到才知道该改什么。 + */ +function shortErrorLabel( + raw: string | null, + t: ReturnType['t'], +): string { + const message = (raw ?? '').trim(); + if (message.startsWith('providerHttpStatus:')) { + return message.split(':')[1] || t('settings.channels.errGeneric'); + } + // 裸状态码也认(历史记录里可能只存了 "401")——状态码本身就是最好的短标签。 + if (/^[1-5]\d{2}$/.test(message)) return message; + if (message === 'providerRequestTimeout' || message.includes('timeout')) { + return t('settings.channels.errTimeout'); + } + if (message === 'providerNetworkError') return t('settings.channels.errNetwork'); + if (message === 'endpointMustUseHttps' || message === 'endpointInvalid') { + return t('settings.channels.errEndpoint'); + } + if (message === 'llmModelMissing' || message === 'asrModelMissing') { + return t('settings.channels.errModel'); + } + return t('settings.channels.errGeneric'); +} + +/** 一天以前的验证结果只能算"旧消息",褪色表示不保证现在还有效。 */ +const STALE_TEST_SECONDS = 24 * 60 * 60; + +function relativeTime(at: number, t: ReturnType['t']): string { + const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at); + if (seconds < 60) return t('settings.channels.justNow'); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return t('settings.channels.minutesAgo', { count: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t('settings.channels.hoursAgo', { count: hours }); + return t('settings.channels.daysAgo', { count: Math.floor(hours / 24) }); +} + +export function ChannelList({ + kind, + autoCreateWhenEmpty = false, +}: { + kind: ChannelKind; + /** 新手引导用:列表为空时直接摊开添加表单,别让新用户对着空列表和一个加号发呆。 */ + autoCreateWhenEmpty?: boolean; +}) { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const os = detectOS(); + const presets = presetsFor(kind, os); + const [channels, setChannels] = useState([]); + const [models, setModels] = useState>({}); + const [loaded, setLoaded] = useState(false); + const [editingId, setEditingId] = useState(null); + /** 新建时先落一张草稿卡片(凭据必须按渠道 id 写入),弹窗直接编辑它。 */ + const [draftId, setDraftId] = useState(null); + const [creatingBusy, setCreatingBusy] = useState(false); + // 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。 + const autoOpenedRef = useRef(false); + + const refresh = useCallback(async () => { + try { + const list = await listChannels(kind); + setChannels(list); + setLoaded(true); + // 卡片上要显示每张卡当前的模型名 —— 凭据按渠道隔离,只能逐个读。 + // 渠道数量是个位数,并发读一轮的开销可以忽略。 + const account = modelAccountFor(kind); + const entries = await Promise.all( + list.map(async channel => { + try { + return [channel.id, (await readCredential(account, channel.id)) ?? ''] as const; + } catch { + return [channel.id, ''] as const; + } + }), + ); + setModels(Object.fromEntries(entries)); + } catch (error) { + console.error('[channels] failed to load', error); + setLoaded(true); + } + }, [kind]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // ── 添加:一步到位 ── + // 点「添加渠道」直接开编辑弹窗(供应商、名字、密钥、测试都在里面)。草稿卡片在 + // 后台先建出来只是因为凭据要按渠道 id 落盘;用户什么都没填就关掉的话它会被回收, + // 不会在列表里留下空卡片。 + const startCreate = useCallback(async () => { + if (creatingBusy) return; + setCreatingBusy(true); + try { + const id = await createChannel(kind, presets[0]?.id ?? '', ''); + setDraftId(id); + await refresh(); + } catch (error) { + console.error('[channels] create failed', error); + emitSaved('failed', t('common.operationFailed')); + } finally { + setCreatingBusy(false); + } + }, [creatingBusy, kind, presets, refresh, t]); + + useEffect(() => { + if (!autoCreateWhenEmpty || !loaded || autoOpenedRef.current) return; + if (channels.length === 0) { + autoOpenedRef.current = true; + void startCreate(); + } + }, [autoCreateWhenEmpty, loaded, channels.length, startCreate]); + + // 生效中的那张 = 第一个启用的(列表已按 order 排好)。 + const activeId = channels.find(c => c.enabled)?.id ?? null; + + // ── 卡片上的验证 ── + // 只在用户点的时候跑:验证是**真实的 API 调用**(LLM 走一次真的润色请求、ASR 会传 + // 一段静音音频上去)。做成打开设置就全部自动验一遍的话,等于每次开设置都按卡片数 + // 烧一遍额度,还容易把自己撞进限流。 + const [testingIds, setTestingIds] = useState>({}); + /** 刚验通过的短暂高亮(id → 延迟 ms),几秒后落回常驻的灰色数字。 */ + const [justPassed, setJustPassed] = useState>({}); + + const runTest = async (channel: Channel) => { + if (testingIds[channel.id]) return; + setTestingIds(prev => ({ ...prev, [channel.id]: true })); + const started = performance.now(); + try { + const result = await validateProviderCredentials(kind, channel.id); + const latency = Math.round(performance.now() - started); + await recordChannelTest( + kind, + channel.id, + result.ok, + result.ok ? latency : null, + result.ok ? null : 'validateFailed', + ); + if (result.ok) { + setJustPassed(prev => ({ ...prev, [channel.id]: latency })); + window.setTimeout(() => { + setJustPassed(prev => { + const next = { ...prev }; + delete next[channel.id]; + return next; + }); + }, 3000); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await recordChannelTest(kind, channel.id, false, null, message); + } catch (recordError) { + console.error('[channels] failed to record test', recordError); + } + } finally { + setTestingIds(prev => ({ ...prev, [channel.id]: false })); + await refresh(); + } + }; + + const onToggle = async (channel: Channel) => { + emitSaved('saving', t('common.saving')); + try { + await setChannelEnabled(kind, channel.id, !channel.enabled); + await refresh(); + emitSaved('saved', t('common.saved')); + } catch (error) { + console.error('[channels] toggle failed', error); + emitSaved('failed', t('common.operationFailed')); + } + }; + + // ── 拖拽排序 ── + // 用 pointer 事件手写,**不用 HTML5 draggable**:Tauri 的 webview 默认开着 + // dragDropEnabled,会把 dragstart/drop 当成文件拖放吞掉,`draggable` 在打包后的 + // app 里根本不触发(浏览器里却是好的,最容易漏测)。pointer 方案还顺带让 + // Windows 与 Android 的行为保持一致。 + const rowsRef = useRef(new Map()); + const channelsRef = useRef([]); + const dragIdRef = useRef(null); + const orderAtDragStartRef = useRef([]); + const [draggingId, setDraggingId] = useState(null); + + useEffect(() => { + channelsRef.current = channels; + }, [channels]); + + const dragCleanupRef = useRef<(() => void) | null>(null); + + /** 指针移到哪张卡片上,就把被拖的那张插到那个位置 —— 卡片实时跟手。 */ + const moveDragTo = (pointerY: number) => { + const dragId = dragIdRef.current; + if (!dragId) return; + let targetId: string | null = null; + for (const [id, element] of rowsRef.current) { + const rect = element.getBoundingClientRect(); + if (pointerY >= rect.top && pointerY <= rect.bottom) { + targetId = id; + break; + } + } + if (!targetId || targetId === dragId) return; + setChannels(prev => { + const from = prev.findIndex(c => c.id === dragId); + const to = prev.findIndex(c => c.id === targetId); + if (from < 0 || to < 0 || from === to) return prev; + const next = [...prev]; + next.splice(to, 0, next.splice(from, 1)[0]); + return next; + }); + }; + + /// 拖拽刚结束时浏览器还会补一个 click。设置弹窗的遮罩层上挂着 onClick={onClose}, + /// 这个补发的 click 会把整个设置面板关掉(拖一次卡片、设置就没了)。在捕获阶段 + /// 吞掉紧随其后的那一个 click,200ms 内没等到就撤掉监听。 + const swallowNextClick = () => { + const handler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener('click', handler, { capture: true, once: true }); + window.setTimeout(() => { + window.removeEventListener('click', handler, { capture: true }); + }, 200); + }; + + const endDrag = async () => { + dragCleanupRef.current?.(); + dragCleanupRef.current = null; + const dragId = dragIdRef.current; + dragIdRef.current = null; + setDraggingId(null); + if (!dragId) return; + swallowNextClick(); + const ids = channelsRef.current.map(c => c.id); + const before = orderAtDragStartRef.current; + if (ids.length === before.length && ids.every((id, index) => id === before[index])) { + return; // 顺序没变,不打扰后端 + } + try { + await reorderChannels(kind, ids); + await refresh(); + emitSaved('saved', t('common.saved')); + } catch (error) { + console.error('[channels] reorder failed', error); + emitSaved('failed', t('common.operationFailed')); + await refresh(); + } + }; + + // 刻意**不用** setPointerCapture:它会把后续事件重定向到手柄,浏览器补发的 click + // 于是落到设置弹窗的遮罩上,一拖就把设置关了。改用 window 级监听,事件目标不变。 + const onDragHandleDown = (event: React.PointerEvent, id: string) => { + event.preventDefault(); + event.stopPropagation(); + dragIdRef.current = id; + orderAtDragStartRef.current = channelsRef.current.map(c => c.id); + setDraggingId(id); + + const onMove = (moveEvent: PointerEvent) => moveDragTo(moveEvent.clientY); + const onUp = () => void endDrag(); + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + dragCleanupRef.current = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }; + + // 组件卸载(比如关掉设置面板)时别把 window 监听留在外面。 + useEffect(() => () => dragCleanupRef.current?.(), []); + + const editingChannel = + channels.find(c => c.id === (draftId ?? editingId)) ?? null; + const isDraft = draftId != null; + + const closeModal = async () => { + const id = draftId; + setDraftId(null); + setEditingId(null); + if (id) { + // 草稿:什么都没填就收走,别在列表里留空卡片。 + try { + await deleteChannelIfBlank(kind, id); + } catch (error) { + console.error('[channels] blank cleanup failed', error); + } + } + await refresh(); + }; + + return ( + +
+ + {t(kind === 'llm' ? 'settings.providers.llmTitle' : 'settings.providers.asrTitle')} + +
+
+ {t('settings.channels.orderHint')} +
+ + {loaded && channels.length === 0 && ( +
+ {t('settings.channels.empty')} +
+ )} + +
+ {channels.map(channel => { + const isActive = channel.id === activeId; + const label = channel.name.trim() || presetLabel(kind, channel.providerType, t); + const model = models[channel.id] ?? ''; + const failed = channel.lastTest && !channel.lastTest.ok; + return ( +
{ + if (element) rowsRef.current.set(channel.id, element); + else rowsRef.current.delete(channel.id); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + // 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。 + padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px', + borderRadius: 10, + border: '0.5px solid var(--ol-line-strong)', + // 「当前在用」只用一条竖条表达位置,**不用绿色也不用文字**: + // 绿色和「生效中」会被读成"这张是健康的",可它只代表排在最前面 —— + // 一张 key 已经失效的卡片照样排第一。健康与否只有验证说了算。 + borderLeft: isActive + ? '2.5px solid var(--ol-blue)' + : '0.5px solid var(--ol-line-strong)', + background: channel.enabled ? 'var(--ol-surface)' : 'var(--ol-bg-2, transparent)', + opacity: draggingId === channel.id ? 0.55 : channel.enabled ? 1 : 0.62, + boxShadow: draggingId === channel.id ? '0 6px 18px rgba(0,0,0,0.14)' : undefined, + transition: draggingId ? undefined : 'opacity 0.16s var(--ol-motion-quick)', + }} + > + onDragHandleDown(e, channel.id)} + onClick={e => e.stopPropagation()} + title={t('settings.channels.dragHint')} + aria-label={t('settings.channels.dragHint')} + style={{ + color: 'var(--ol-ink-4)', + fontSize: 13, + flexShrink: 0, + cursor: draggingId === channel.id ? 'grabbing' : 'grab', + // 触摸设备上按住手柄不要变成页面滚动。 + touchAction: 'none', + padding: '2px 2px', + userSelect: 'none', + }} + > + ⠿ + +
+ {label} + {/* minHeight 保证「未命名 + 没验证过」的卡片不会比别的矮一截, + 列表高度参差看起来像坏了。 */} +
+ {/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */} + {channel.name.trim() && {presetLabel(kind, channel.providerType, t)}} + {model && {model}} + {/* 验证结果什么时候来的 —— 让"这条结论会过期"这件事可见。 */} + {channel.lastTest && {relativeTime(channel.lastTest.at, t)}} +
+
+ void runTest(channel)} + t={t} + /> + void onToggle(channel)} /> + +
+ ); + })} +
+ + + + {editingChannel && ( + void closeModal()} + onChanged={refresh} + /> + )} +
+ ); +} + +/** + * 卡片上的验证按钮 —— **按钮自己就是结果容器**,不给结果另找地方摆文字。 + * + * 通过时只显示延迟数字:它既说明"通了",又带信息量(一眼看出哪张快); + * 再写一句"验证通过"是废话还占地方。失败则必须给出能指导行动的短标签 + * (401 改 key / 429 等会儿 / 超时查网络)。 + * + * 宽度固定:让 `验证` → `284ms` → `✗ 401` 的文字变化不会把开关和箭头挤来挤去。 + */ +function VerifyButton({ + channel, + testing, + justPassedMs, + onRun, + t, +}: { + channel: Channel; + testing: boolean; + justPassedMs?: number; + onRun: () => void; + t: ReturnType['t']; +}) { + const last = channel.lastTest; + const stale = + last != null && Math.floor(Date.now() / 1000) - last.at > STALE_TEST_SECONDS; + + let label: string; + let color = 'var(--ol-ink-3)'; + if (testing) { + label = '···'; + color = 'var(--ol-ink-4)'; + } else if (justPassedMs != null) { + label = `✓ ${justPassedMs}ms`; + color = 'var(--ol-ok)'; + } else if (!last) { + label = t('settings.channels.verify'); + } else if (last.ok) { + label = last.latencyMs != null ? `${last.latencyMs}ms` : '✓'; + // 旧结果褪成浅灰:不保证现在还有效。 + color = stale ? 'var(--ol-ink-4)' : 'var(--ol-ink-3)'; + } else { + label = `✗ ${shortErrorLabel(last.error, t)}`; + color = 'var(--ol-warn)'; + } + + return ( + + ); +} + +/** + * 「服务 → AI 提供商」面板:LLM 与 ASR 两张渠道列表。 + * + * 保留 `ProvidersSection` 这个名字与 `kind` 签名,让设置页 tabs 与新手引导的调用点 + * 不用改。渠道化之后它只是两个 的容器。 + */ +export function ProvidersSection({ + kind = 'all', + autoCreateWhenEmpty = false, +}: { + kind?: 'all' | 'llm' | 'asr'; + autoCreateWhenEmpty?: boolean; +} = {}) { + const { t } = useTranslation(); + const { prefs } = useHotkeySettings(); + // 多模态管线接管(issue #902):多模态模式下隐藏传统 llm/asr 渠道列表, + // 凭据两套并存但停用,切回即恢复(与合并前 beta 语义一致)。 + const multimodalMode = + prefs?.multimodalPipelineEnabled === true && prefs?.pipelineMode === 'multimodal'; + return ( + <> + {kind === 'all' && } + {kind === 'all' && !multimodalMode && ( +
+ {t('settings.providers.credentialStorageNotice')} +
+ )} + {!multimodalMode && (kind === 'all' || kind === 'llm') && ( + + )} + {!multimodalMode && (kind === 'all' || kind === 'asr') && ( + + )} + + ); +} + +/** + * 添加与编辑共用的同一个弹窗 —— 供应商、名字、凭据、测试连通都在这一屏里。 + * + * 刻意不做「先创建、再填凭据」的两步:那只是实现上需要先有渠道 id 才能写凭据, + * 不该变成用户多点一次。 + */ +function ChannelModal({ + kind, + channel, + presets, + isDraft, + mobile, + onClose, + onChanged, +}: { + kind: ChannelKind; + channel: Channel; + presets: PresetOption[]; + /** 新建流程中的草稿卡片:标题用「添加渠道」,且允许被空回收。 */ + isDraft: boolean; + mobile: boolean; + onClose: () => void; + onChanged: () => void | Promise; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(channel.name); + const [providerType, setProviderType] = useState(channel.providerType); + const [confirmDelete, setConfirmDelete] = useState(false); + + const saveName = async () => { + if (name.trim() === channel.name.trim()) return; + try { + await renameChannel(kind, channel.id, name.trim()); + await onChanged(); + } catch (error) { + console.error('[channels] rename failed', error); + emitSaved('failed', t('common.operationFailed')); + } + }; + + const changeProvider = async (next: string) => { + const previous = providerType; + setProviderType(next); + try { + await setChannelProviderType(kind, channel.id, next); + await onChanged(); + } catch (error) { + console.error('[channels] change provider failed', error); + setProviderType(previous); + emitSaved('failed', t('common.operationFailed')); + } + }; + + const remove = async () => { + try { + await deleteChannel(kind, channel.id); + emitSaved('saved', t('common.saved')); + onClose(); + } catch (error) { + console.error('[channels] delete failed', error); + emitSaved('failed', t('common.operationFailed')); + } + }; + + const isLocalEngine = LOCAL_ASR_PROVIDER_IDS.includes(providerType); + + return ( + +
+ {t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')} +
+ + + void changeProvider(next)} + options={presets.map(p => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + }))} + ariaLabel={t('settings.channels.providerLabel')} + style={{ ...inputStyle, width: '100%', marginBottom: 12 }} + /> + + + setName(e.target.value)} + onBlur={() => void saveName()} + placeholder={t('settings.channels.namePlaceholder')} + style={{ ...inputStyle, width: '100%', marginBottom: 14 }} + /> + + {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} + void onChanged()} + /> + + {isLocalEngine && ( +
+ {t('settings.channels.localEngineModelHint')} +
+ )} + +
+ {confirmDelete ? ( +
+ + {t('settings.channels.deleteConfirm')} + + + +
+ ) : ( + + )} + +
+
+ ); +} + +const fieldLabel: CSSProperties = { + display: 'block', + fontSize: 12, + fontWeight: 500, + color: 'var(--ol-ink-2)', + marginBottom: 5, +}; + +const iconBtn: CSSProperties = { + width: 30, + height: 30, + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, + background: 'var(--ol-surface)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: 'var(--ol-ink-3)', + cursor: 'default', + flexShrink: 0, +}; + +const addBtn: CSSProperties = { + height: 34, + padding: '0 14px', + border: '0.5px dashed var(--ol-line-strong)', + borderRadius: 9, + background: 'transparent', + color: 'var(--ol-ink-3)', + cursor: 'default', + fontSize: 12.5, + fontWeight: 500, + width: '100%', +}; + +const primaryBtn: CSSProperties = { + height: 32, + padding: '0 14px', + border: '0.5px solid var(--ol-blue)', + borderRadius: 8, + background: 'var(--ol-blue)', + color: '#fff', + cursor: 'default', + fontSize: 12.5, + fontWeight: 500, +}; + +const ghostBtn: CSSProperties = { + height: 32, + padding: '0 14px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, + background: 'var(--ol-surface)', + color: 'var(--ol-ink-2)', + cursor: 'default', + fontSize: 12.5, + fontWeight: 500, +}; + +const dangerBtn: CSSProperties = { + ...ghostBtn, + borderColor: 'var(--ol-warn)', + color: 'var(--ol-warn)', +}; diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 0aec090c0..dbdb7485e 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -8,8 +8,7 @@ import { detectOS } from '../../components/WindowChrome'; import { listProviderModels, readCredential, - setActiveAsrProvider, - setActiveLlmProvider, + recordChannelTest, setActiveOmniProvider, setCredential, validateProviderCredentials, @@ -232,6 +231,14 @@ type OmniPresetId = typeof OMNI_PRESETS[number]['id']; const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; +/// 无 key / 无地址的本地引擎:卡片编辑里没有凭据字段,模型下载仍在「高级 → 本地模型」。 +export const LOCAL_ASR_PROVIDER_IDS: string[] = [ + 'local-qwen3', + 'sherpa-onnx-local', + 'foundry-local-whisper', + 'apple-speech', +]; + // ASR_PRESETS 已上移到 settings/shared.tsx 作为单一来源(AsrPresetId 由其派生, // Overview 的显示名映射也从那里取)。新增厂商的步骤见 shared.tsx 的注释。 @@ -268,219 +275,48 @@ const WHISPER_COMPAT_ASR_PROVIDERS: AsrPresetId[] = ['whisper', 'groq', 'silicon /** 模型预设下拉里的「自定义模型…」哨兵值:选中即切回输入框手输。 */ const CUSTOM_MODEL_OPTION_VALUE = '__custom_model__'; -type ProvidersSectionKind = 'all' | 'llm' | 'asr'; - -interface ProvidersSectionProps { - kind?: ProvidersSectionKind; -} - -export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { +/** + * 一张渠道卡片的凭据字段区(编辑弹窗的主体)。 + * + * 渠道化之前这里是「下拉选厂商 + 一组字段」;现在厂商由卡片自身的 providerType + * 决定,字段一律按 `channelId` 作用域读写(后端 read_credential/set_credential 的 + * `provider` 参数收的就是渠道 id),因此同一家厂商的多张卡片互不干扰。 + */ +export function ChannelCredentialFields({ + kind, + providerType, + channelId, + onTested, +}: { + kind: 'llm' | 'asr'; + providerType: string; + channelId: string; + /** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */ + onTested?: () => void; +}) { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const mobile = useMobileLayout(); - // `*Provider` 立即跟随 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await) - // - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry) - // `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不 - // 会覆盖后发的 commit。 - const [llmProvider, setLlmProvider] = useState('ark'); - const [asrProvider, setAsrProvider] = useState('volcengine'); - const [omniProvider, setOmniProvider] = useState('custom'); - const [committedLlmProvider, setCommittedLlmProvider] = useState('ark'); - const [committedAsrProvider, setCommittedAsrProvider] = useState('volcengine'); - const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); - const llmSwitchSeqRef = useRef(0); - const asrSwitchSeqRef = useRef(0); - const omniSwitchSeqRef = useRef(0); const [llmModelRevision, setLlmModelRevision] = useState(0); const [asrModelRevision, setAsrModelRevision] = useState(0); - const [omniModelRevision, setOmniModelRevision] = useState(0); - const os = detectOS(); - const unifiedBailian = committedAsrProvider === 'bailian'; + const unifiedBailian = providerType === 'bailian'; const [bailianModel, setBailianModel] = useState(''); const [volcengineAuthMode, setVolcengineAuthMode] = useState<'app_id_token' | 'api_key'>('app_id_token'); useEffect(() => { - if (committedAsrProvider === 'volcengine') { - readCredential('volcengine.auth_mode', 'volcengine') + if (providerType === 'volcengine') { + readCredential('volcengine.auth_mode', channelId) .then(v => { if (v === 'api_key') setVolcengineAuthMode('api_key'); else setVolcengineAuthMode('app_id_token'); }) .catch(() => setVolcengineAuthMode('app_id_token')); } - }, [committedAsrProvider]); + }, [providerType, channelId]); useEffect(() => { - if (committedAsrProvider !== 'bailian') setBailianModel(''); - }, [committedAsrProvider]); - // 本地引擎(qwen3 / sherpa / foundry)的已下载模型直接作为 ASR 供应商下拉的 - // 可选项:模型 ID 就是选项,选中即使用该模型(不用先选引擎再选模型)。 - // 一次拉全三个引擎;并监听下载进度事件,在本地模型下载完成后自动刷新列表。 - const [localModelOptions, setLocalModelOptions] = useState< - { engine: 'qwen3' | 'sherpa' | 'foundry'; id: string; name: string; isDownloaded: boolean }[] - >([]); - // 同 provider 内切换本地模型的乐观值:下拉立即显示用户点的模型,不等后端 - // set_settings + prefs:changed 事件回来(回来前的几帧会闪回旧模型 = 闪烁)。 - const [localAsrModelDraft, setLocalAsrModelDraft] = useState(null); - useEffect(() => { - // 供应商切换后旧引擎的 draft 不再适用,清掉让 asrValue 回退 prefs。 - setLocalAsrModelDraft(null); - }, [committedAsrProvider]); - useEffect(() => { - let cancelled = false; - // 平台分支拉取:sherpa/foundry 的 catalog 命令只在 Windows 注册,macOS 上 - // invoke 未注册命令会 reject——Promise.all 拉三个会把 qwen3 的结果也一起 - // 吞掉(下拉永远只剩引擎级入口)。按平台只拉本平台存在的引擎。 - const fetchAll = async () => { - try { - const qwen3 = await listLocalAsrModels(); - const extra = - os === 'win' - ? await Promise.all([ - getSherpaOnnxAsrCatalog(), - getFoundryLocalAsrCatalog(), - ]) - : null; - if (cancelled) return; - const next = [ - ...qwen3.map(m => ({ engine: 'qwen3' as const, id: m.id, name: m.id, isDownloaded: m.isDownloaded })), - ...(extra?.[0] ?? []).map(c => ({ engine: 'sherpa' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })), - ...(extra?.[1] ?? []).map(c => ({ engine: 'foundry' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })), - ]; - // 浅比较:数据没变就不 setState,避免 3s 轮询让下拉每轮重渲染(闪烁)。 - setLocalModelOptions(prev => - prev.length === next.length && - prev.every((m, i) => - m.engine === next[i].engine && - m.id === next[i].id && - m.name === next[i].name && - m.isDownloaded === next[i].isDownloaded, - ) - ? prev - : next, - ); - } catch { - if (!cancelled) setLocalModelOptions([]); - } - }; - void fetchAll(); - // 3s 轮询磁盘状态:模型被外部删除(或下载完成后)下拉选项自动跟随, - // 用户不需要重开设置页。本地 fs 检查很轻,无感。 - const pollTimer = window.setInterval(() => { - void fetchAll(); - }, 3000); - // 下载完成事件驱动刷新:本页下方「本地模型」看板下载完模型后,下拉立刻出现新选项。 - let unlistenQ: (() => void) | undefined; - let unlistenS: (() => void) | undefined; - void import('@tauri-apps/api/event').then(({ listen }) => { - void listen<{ phase: string }>('local-asr-download-progress', (e) => { - if (e.payload.phase === 'finished') void fetchAll(); - }).then(fn => { if (cancelled) fn(); else unlistenQ = fn; }).catch(() => {}); - void listen<{ phase: string }>('sherpa-onnx-asr-download-progress', (e) => { - if (e.payload.phase === 'finished') void fetchAll(); - }).then(fn => { if (cancelled) fn(); else unlistenS = fn; }).catch(() => {}); - }).catch(() => {}); - return () => { - cancelled = true; - window.clearInterval(pollTimer); - unlistenQ?.(); - unlistenS?.(); - }; - }, []); - - // 本地引擎(qwen3 / foundry / sherpa)直接作为常规选项放进主下拉(按平台 gating), - // 选项名标注「本地」——选了本地模型供应商,ASR 就用本地模型(与 Apple 语音同理), - // 不再需要单独的启用开关。模型下载与管理在「服务 → 本地模型」的看板里。 - const visibleAsrPresets = ASR_PRESETS.filter( - p => (p.id !== 'foundry-local-whisper' || os === 'win') - && (p.id !== 'sherpa-onnx-local' || os === 'win') - && (p.id !== 'local-qwen3' || os === 'mac') - && (p.id !== 'apple-speech' || os === 'mac') - // 百炼三协议收成一个「阿里云百炼」入口(id=bailian)+ 模型下拉。qwen3 / fun-asr-flash - // 两个旧 id 作隐藏别名:新用户下拉里看不到,只有已经停在该 id 上的老用户仍显示, - // 保证其配置不被打断(见 coordinator::resolve_effective_asr_provider 的向后兼容)。 - && (p.id !== 'bailian-qwen3-realtime' || asrProvider === 'bailian-qwen3-realtime') - && (p.id !== 'bailian-fun-asr-flash' || asrProvider === 'bailian-fun-asr-flash'), - ); - - useEffect(() => { - if (!prefs) return; - const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider); - const llmId = knownLlm ? knownLlm.id : 'custom'; - setLlmProvider(llmId); - setCommittedLlmProvider(llmId); - // ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然 - // 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card - // 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。 - const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider); - const asrId = knownAsr ? knownAsr.id : 'volcengine'; - setAsrProvider(asrId); - setCommittedAsrProvider(asrId); - const knownOmni = OMNI_PRESETS.find(x => x.id === prefs.activeOmniProvider); - const omniId = knownOmni ? knownOmni.id : 'custom'; - setOmniProvider(omniId); - setCommittedOmniProvider(omniId); - }, [prefs, os]); - - // issue #219 / #220 P2: - // 1. 立刻 setLlmProvider —— 受控 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder - // 会先于实际数据切换、视觉上对不上。 - const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; - const codexOAuthSelected = committedLlmProvider === 'codex_oauth'; - const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider); - const omniPreset = OMNI_PRESETS.find(p => p.id === committedOmniProvider); - const showLlm = kind === 'all' || kind === 'llm'; - const showAsr = kind === 'all' || kind === 'asr'; - const multimodalEnabled = prefs?.multimodalPipelineEnabled === true; - const multimodalMode = multimodalEnabled && prefs?.pipelineMode === 'multimodal'; - return ( - <> - {kind === 'all' && ( -
- {t('settings.providers.credentialStorageNotice')} -
- )} - {kind === 'all' && multimodalEnabled && ( -
- -
-
- {(['traditional', 'multimodal'] as const).map(mode => ( - - ))} -
-
-
-
- {t('settings.providers.pipelineIsolationNotice')} -
-
- )} - {showLlm && !multimodalMode && ( - -
- {t('settings.providers.llmTitle')} -
- {/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行, - 视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */} - - onLlmProviderChange(next as LlmPresetId)} - options={LLM_PRESETS.map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - }))} - ariaLabel={t('settings.providers.providerLabel')} - style={{ width: mobile ? '100%' : 200, maxWidth: '100%', minWidth: 0 }} - /> - + if (kind === 'llm') { + const preset = LLM_PRESETS.find(p => p.id === providerType) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; + const codexOAuthSelected = providerType === 'codex_oauth'; + return ( + <> {codexOAuthSelected ? (
{t('settings.providers.codexOAuthNotice')}
) : ( <> - - - {committedLlmProvider === 'custom' && ( + + + {providerType === 'custom' && ( <> )} - )} /> - setLlmModelRevision(v => v + 1)} /> -
- )} + setLlmModelRevision(v => v + 1)} onTested={onTested} /> + + ); + } - {showAsr && !multimodalMode && ( - -
- {t('settings.providers.asrTitle')} -
- {/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示, - 未激活时不显示提示。 */} - - {(() => { - // 本地引擎的已下载模型直接作为下拉选项(value = "引擎:模型ID"): - // 选了哪个模型 ID,就用哪个模型——不用先选引擎再选模型。 - // 引擎 → 模型数据源映射。 - const LOCAL_ENGINE_OF: Record = { - 'local-qwen3': 'qwen3', - 'sherpa-onnx-local': 'sherpa', - 'foundry-local-whisper': 'foundry', - }; - const localPresets = visibleAsrPresets.filter(p => isLocalAsrPreset(p.id)); - const localOptions = localPresets.flatMap(p => { - const downloaded = localModelOptions.filter( - m => m.engine === LOCAL_ENGINE_OF[p.id] && m.isDownloaded, - ); - if (downloaded.length === 0) { - // 还没下载模型:保留引擎入口,选它之后去「本地模型」看板下载。 - return [{ - value: p.id, - label: `${t(`settings.providers.presets.${p.nameKey}`)}(${t('settings.providers.localTag')})`, - }]; + const asrPreset = ASR_PRESETS.find(p => p.id === providerType); + + if (providerType === 'volcengine') { + return ( + <> + + { + const mode = v as 'app_id_token' | 'api_key'; + const prev = volcengineAuthMode; + setVolcengineAuthMode(mode); + try { + await setCredential('volcengine.auth_mode', mode, channelId); + } catch (error) { + // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, + // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 + console.error('[settings] failed to save volcengine auth mode', error); + setVolcengineAuthMode(prev); + emitSaved('failed', t('common.operationFailed')); } - return downloaded.map(m => ({ - value: `${p.id}:${m.id}`, - label: `${m.name}(${t('settings.providers.localTag')})`, - })); - }); - // 受控 value:本地引擎激活且 active 模型已下载时显示 "引擎:模型ID"。 - // draft(用户刚点的模型)优先于 prefs——同 provider 换模型时后端 - // 还没回写完成,直接读 prefs 会闪回旧模型。 - const activeModelId = committedAsrProvider === 'local-qwen3' - ? prefs?.localAsrActiveModel - : committedAsrProvider === 'sherpa-onnx-local' - ? prefs?.sherpaOnnxModel - : committedAsrProvider === 'foundry-local-whisper' - ? prefs?.foundryLocalAsrModel - : undefined; - const resolvedModelId = - localAsrModelDraft && - localModelOptions.some(m => m.id === localAsrModelDraft && m.isDownloaded) - ? localAsrModelDraft - : activeModelId; - const asrValue = - isLocalAsrPreset(committedAsrProvider) && - resolvedModelId && - localModelOptions.some(m => m.id === resolvedModelId && m.isDownloaded) - ? `${committedAsrProvider}:${resolvedModelId}` - : asrProvider; - // 平台不匹配的旧配置(如 Windows 上仍激活 local-qwen3):补一个选项兜底。 - const hiddenLocalActive: AsrPresetId | null = - !visibleAsrPresets.some(p => p.id === committedAsrProvider) - ? committedAsrProvider - : null; - // 本地引擎激活但 active 模型不在已下载列表(模型被删)时,value 无匹配 - // 选项——补引擎兜底项让下拉有显示、可切走。hiddenLocalActive 已兜底时不重复加。 - const unmatchedLocalPreset = - isLocalAsrPreset(asrValue) && !hiddenLocalActive - ? ASR_PRESETS.find(p => p.id === asrValue) - : undefined; - const hiddenLocalNameKey = hiddenLocalActive === 'local-qwen3' - ? 'asrLocalQwen3' - : hiddenLocalActive === 'foundry-local-whisper' - ? 'asrFoundryLocalWhisper' - : hiddenLocalActive === 'sherpa-onnx-local' - ? 'asrSherpaOnnxLocal' - : hiddenLocalActive === 'apple-speech' - ? 'asrAppleSpeech' - : null; - return ( -
- { - const sep = next.indexOf(':'); - if (sep > 0 && isLocalAsrPreset(next.slice(0, sep))) { - void onAsrProviderChange(next.slice(0, sep) as AsrPresetId, next.slice(sep + 1)); - } else { - void onAsrProviderChange(next as AsrPresetId); - } - }} - options={[ - ...visibleAsrPresets.filter(p => !isLocalAsrPreset(p.id)).map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - })), - ...localOptions, - ...(unmatchedLocalPreset && !localOptions.some(o => o.value === asrValue) - ? [{ - value: asrValue, - label: `${t(`settings.providers.presets.${unmatchedLocalPreset.nameKey}`)}(${t('settings.providers.localTag')})`, - }] - : []), - ...(hiddenLocalActive && hiddenLocalNameKey - ? [{ - value: hiddenLocalActive, - label: t(`settings.providers.presets.${hiddenLocalNameKey}`), - }] - : []), - ]} - ariaLabel={t('settings.providers.providerLabel')} - style={{ width: mobile ? '100%' : 200, maxWidth: '100%', minWidth: 0 }} - /> - {hiddenLocalActive && ( -
- {t('settings.providers.asrProviderTakenOver')} -
- )} -
- ); - })()} + }} + options={[ + { value: 'app_id_token', label: t('settings.providers.volcengineAuthModeAppIdToken') }, + { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, + ]} + ariaLabel={t('settings.providers.volcengineAuthModeLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }} + />
- {/* 供应商切换时 ASR 板块高度 / 内容会变:keyed 淡入动画平滑过渡(ol-tab-fade)。 */} -
- {committedAsrProvider === 'volcengine' ? ( - <> - - { - const mode = v as 'app_id_token' | 'api_key'; - const prev = volcengineAuthMode; - setVolcengineAuthMode(mode); - try { - await setCredential('volcengine.auth_mode', mode, committedAsrProvider); - } catch (error) { - // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, - // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 - console.error('[settings] failed to save volcengine auth mode', error); - setVolcengineAuthMode(prev); - emitSaved('failed', t('common.operationFailed')); - } - }} - options={[ - { value: 'app_id_token', label: t('settings.providers.volcengineAuthModeAppIdToken') }, - { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, - ]} - ariaLabel={t('settings.providers.volcengineAuthModeLabel')} - style={{ width: mobile ? '100%' : 260, maxWidth: '100%', minWidth: 0 }} - /> - - {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) - 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} - {volcengineAuthMode === 'app_id_token' ? ( - <> - - - - ) : ( - - )} - -
- {volcengineAuthMode === 'api_key' - ? t('settings.providers.volcengineApiKeyNote') - : t('settings.providers.volcengineMappingNote')} -
- - ) : committedAsrProvider === 'iflytek' ? ( + {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) + 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} + {volcengineAuthMode === 'app_id_token' ? ( <> - - -
- {t('settings.providers.xfyunNote')} -
+ + - ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? ( - // 本地引擎激活:模型选择已并进上方供应商下拉(模型 ID 直选),这里只留提示。 - // Apple 语音零模型选择。 - committedAsrProvider === 'apple-speech' ? ( -
- {t('settings.providers.appleSpeechLocalNote')} -
- ) : ( -
- {t('settings.providers.localEngineNote')} -
- ) ) : ( - <> - - {/* 统一百炼保留 endpoint 供用户选择区域或工作空间域名;后端按模型转换协议与路径。 */} - - ({ value: m, label: m })) - : WHISPER_COMPAT_ASR_PROVIDERS.includes(committedAsrProvider) - ? OPENAI_COMPAT_ASR_MODELS.map(m => ({ value: m, label: m })) - : undefined} /> - {unifiedBailian && ( - - )} - {unifiedBailian && bailianModelSupportsVocabulary(bailianModel) && ( - <> - -
- {t('settings.providers.bailianVocabularyIdNote')} -
- - )} - {committedAsrProvider === 'elevenlabs' && ( -
- {t('settings.providers.elevenLabsUploadNotice')} -
- )} - {committedAsrProvider === 'zenmux' && ( -
- {t('settings.providers.zenmuxVocabularyNote')} -
- )} - {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} - setAsrModelRevision(v => v + 1)} /> - {(committedAsrProvider === 'openai-compatible' || committedAsrProvider === 'zenmux') && ( - - )} - + )} + +
+ {volcengineAuthMode === 'api_key' + ? t('settings.providers.volcengineApiKeyNote') + : t('settings.providers.volcengineMappingNote')}
- - )} - {showLlm && multimodalMode && ( - -
- {t('settings.providers.omniTitle')} + setAsrModelRevision(v => v + 1)} onTested={onTested} /> + + ); + } + + if (providerType === 'iflytek') { + return ( + <> + + +
+ {t('settings.providers.xfyunNote')}
- - onOmniProviderChange(next as OmniPresetId)} - options={OMNI_PRESETS.map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - }))} - ariaLabel={t('settings.providers.providerLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} + setAsrModelRevision(v => v + 1)} onTested={onTested} /> + + ); + } + + // 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与 + // 切换仍由「高级 → 本地模型」里的 负责,这里只说明一句。 + if (LOCAL_ASR_PROVIDER_IDS.includes(providerType)) { + return ( +
+ {t('settings.providers.localEngineNoCredentials')} +
+ ); + } + + return ( + <> + + {/* 统一百炼保留 endpoint 供用户选择区域或工作空间域名;后端按模型转换协议与路径。 */} + + ({ value: m, label: m })) + : WHISPER_COMPAT_ASR_PROVIDERS.includes(providerType as AsrPresetId) + ? OPENAI_COMPAT_ASR_MODELS.map(m => ({ value: m, label: m })) + : undefined} /> + {unifiedBailian && ( + + )} + {unifiedBailian && bailianModelSupportsVocabulary(bailianModel) && ( + <> + -
- - - {committedOmniProvider === 'custom' && ( - <> - - - - )} - - setOmniModelRevision(v => v + 1)} - /> - +
+ {t('settings.providers.bailianVocabularyIdNote')} +
+ + )} + {providerType === 'elevenlabs' && ( +
+ {t('settings.providers.elevenLabsUploadNotice')} +
+ )} + {providerType === 'zenmux' && ( +
+ {t('settings.providers.zenmuxVocabularyNote')} +
+ )} + {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} + setAsrModelRevision(v => v + 1)} onTested={onTested} /> + {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( + )} ); @@ -1279,7 +711,7 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const mobile = useMobileLayout(); const [models, setModels] = useState([]); @@ -1292,34 +724,53 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetc setMessage(nextMessage); }; + // 把测试结果落到渠道上(卡片据此显示延迟或标红)。失败不打断主流程: + // 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。 + const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => { + // Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。 + if (!provider || kind === 'omni') return; + try { + await recordChannelTest(kind, provider, ok, latencyMs, message); + onTested?.(); + } catch (error) { + console.error('[settings] failed to record channel test', error); + } + }; + const validate = async () => { setModels([]); setSelectedModel(''); setResult('loading', t('settings.providers.validating')); + const started = performance.now(); try { - const result = await validateProviderCredentials(kind); + const result = await validateProviderCredentials(kind, provider); + const latency = Math.round(performance.now() - started); setResult( result.ok ? 'success' : 'error', t(result.ok ? 'settings.providers.validateSuccess' : 'settings.providers.validateFailed'), ); + await persistTest(result.ok, result.ok ? latency : null, result.ok ? null : 'validateFailed'); } catch (error) { const message = error instanceof Error ? error.message : String(error); if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) { setResult('empty', t('settings.providers.modelMissing')); + await persistTest(false, null, message); return; } if (message === 'modelsEmpty') { setResult('empty', t('settings.providers.modelsEmpty')); + await persistTest(false, null, message); return; } setResult('error', providerErrorMessage(error, t)); + await persistTest(false, null, message); } }; const loadModels = async () => { setResult('loading', t('settings.providers.loadingModels')); try { - const result = await listProviderModels(kind); + const result = await listProviderModels(kind, provider); setModels(result.models); if (result.models.length === 0) { setResult('empty', t('settings.providers.modelsEmpty')); @@ -1669,3 +1120,184 @@ const iconBtnStyle: CSSProperties = { color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0, transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }; + +/** + * 多模态(Omni)配置卡片:仅在「高级 → 实验性」里打开多模态管线后出现。 + * + * 它不参与渠道排序——Omni 是独立命名空间,渠道化范围只覆盖 ASR/LLM + * (见 docs/provider-channels-plan.md 的分期);管道模式切换沿用既有语义: + * 多模态模式下隐藏传统 llm/asr 渠道列表,凭据两套并存但停用,切回即恢复。 + */ +export function OmniChannelSection() { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const { prefs, updatePrefs } = useHotkeySettings(); + const [omniProvider, setOmniProvider] = useState('custom'); + const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); + const omniSwitchSeqRef = useRef(0); + const [omniModelRevision, setOmniModelRevision] = useState(0); + + useEffect(() => { + if (!prefs) return; + const knownOmni = OMNI_PRESETS.find(x => x.id === prefs.activeOmniProvider); + const omniId = knownOmni ? knownOmni.id : 'custom'; + setOmniProvider(omniId); + setCommittedOmniProvider(omniId); + }, [prefs]); + + // 与 LLM 卡同语义:受控下拉立即反馈 + committed 控制 CredentialField remount + // + seq 守卫防 stale 覆盖,只是凭据落到 omni.* 槽。 + const onOmniProviderChange = async (id: OmniPresetId) => { + setOmniProvider(id); + const seq = ++omniSwitchSeqRef.current; + emitSaved('saving', t('common.saving')); + let backendSwitched = false; + try { + await setActiveOmniProvider(id); + backendSwitched = true; + if (seq !== omniSwitchSeqRef.current) return; + if (prefs) { + const next = { ...prefs, activeOmniProvider: id }; + await updatePrefs(next); + if (seq !== omniSwitchSeqRef.current) return; + } + const preset = OMNI_PRESETS.find(p => p.id === id); + // 切到非 custom 预设强制覆盖 endpoint/model 默认值(与 LLM 卡同语义), + // 保证「切换」真切到位,不残留旧厂商的槽值。 + if (preset && preset.id !== 'custom') { + if (preset.baseUrl) { + await setCredential('omni.endpoint', preset.baseUrl); + if (seq !== omniSwitchSeqRef.current) return; + } + if (preset.modelPlaceholder) { + await setCredential('omni.model', preset.modelPlaceholder); + if (seq !== omniSwitchSeqRef.current) return; + } + } + setCommittedOmniProvider(id); + emitSaved('saved', t('common.saved')); + } catch (err) { + if (seq === omniSwitchSeqRef.current) { + emitSaved('failed', t('common.operationFailed')); + if (!backendSwitched) { + setOmniProvider(committedOmniProvider); + } + } + console.error('[settings] switch omni provider failed', err); + } + }; + + // 识别管线模式:切换只改偏好,不删除另一套凭据,切回即恢复;运行时只读当前模式。 + const onPipelineModeChange = (mode: 'traditional' | 'multimodal') => { + if (!prefs) return; + void updatePrefs(current => ({ ...current, pipelineMode: mode })).catch(error => { + console.error('[settings] failed to update pipeline mode', error); + emitSaved('failed', t('common.operationFailed')); + }); + }; + + if (prefs?.multimodalPipelineEnabled !== true) return null; + const multimodalMode = prefs?.pipelineMode === 'multimodal'; + const omniPreset = OMNI_PRESETS.find(p => p.id === committedOmniProvider); + + return ( + <> +
+ +
+
+ {(['traditional', 'multimodal'] as const).map(mode => ( + + ))} +
+
+
+
+ {t('settings.providers.pipelineIsolationNotice')} +
+
+ {multimodalMode && ( + +
+ {t('settings.providers.omniTitle')} +
+ + onOmniProviderChange(next as OmniPresetId)} + options={OMNI_PRESETS.map(p => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + }))} + ariaLabel={t('settings.providers.providerLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} + /> + + + + {committedOmniProvider === 'custom' && ( + <> + + + + )} + + setOmniModelRevision(v => v + 1)} + /> +
+ )} + + ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index b830b2649..362b01e86 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -8,7 +8,7 @@ import { ShortcutsSection } from './ShortcutsSection'; import { SelectionPolishSection } from './SelectionPolishSection'; import { LanguageSection } from './LanguageSection'; import { ThemeSection } from './ThemeSection'; -import { ProvidersSection } from './ProvidersSection'; +import { ProvidersSection } from './ChannelList'; import { NetworkSection } from './NetworkSection'; import { MarketplaceSection } from './MarketplaceSection'; import { PermissionsSection } from './PermissionsSection';