From bfa0661d8aa683af492fec1a5c403784bead700c Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 4 Aug 2026 22:08:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(providers):=20LLM/ASR=20=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E6=94=B9=E6=88=90=E6=B8=A0=E9=81=93=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E2=80=94=E2=80=94=E5=90=8C=E4=B8=80=E5=AE=B6=E5=8F=AF?= =?UTF-8?q?=E5=AD=98=E5=A4=9A=E6=8A=8A=20key=EF=BC=8C=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E5=8D=B3=E4=BC=98=E5=85=88=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来一个供应商只能存一份配置,换 key 只能把旧的覆盖掉;想在主号/备号之间来回切, 每次都要重新粘贴。这个 PR 把「服务 → AI 提供商」从「下拉选厂商 + 一组字段」改成 一张张可命名、可排序、可开关的渠道卡片。 心智只有一条:**排序即优先级,列表里第一个启用的就是当前生效的渠道**。关掉的渠道 自动沉到末尾;后端不另存「当前选中」,避免「列表第一张是 A、实际请求打的是 B」 这种两处真相。 本 PR 只做多渠道的存储、编辑与切换,**不含失败重试与故障转移**(那是下一个 PR, 需要先把听写/润色链路里几十处隐式读 active 凭据的地方显式化)。 存储与迁移 - ChannelMeta(providerType / order / enabled / lastTest)以 serde(flatten) 嵌进 ASR、LLM 两个 entry;v1 老 payload 照常反序列化。 - providerType 与渠道 id 解耦:同一家厂商的多张卡片各有自己的 id,但 providerType 都指向同一个厂商实现。coordinator::resolve_effective_asr_provider 和 commands 里 几十处 `== PROVIDER_ID` 的比较依赖它,拿成 id 会让整个 ASR 路由失效。 - v1→v2 迁移幂等:迁移出的渠道 **id 沿用原 preset id**,不生成 uuid,老用户的 map key 一个字节都不变,重复执行结果一致。 - 迁移只在内存里做、不主动落盘:启动时写 keyring 会在 macOS 触发钥匙串 ACL 弹窗, 留给下一次真实写入顺带固化。 - clean_credentials 原本会 retain(!is_empty) 删掉空 entry —— 刚点「添加渠道」、名字 取好了还没填 key 的卡片会被静默删掉,改为「渠道卡片只能由用户显式删除」。 - ChannelMeta 手写 Default(derive 会让 enabled=false,而 write_account 用 entry().or_default() 建 entry,新写入的渠道会一出生就被禁用)。 - Windows 全新安装预置一张 Foundry 本地 ASR 卡片,保住开箱即用。 IPC - 新增 list/create/rename/delete/set_enabled/reorder/record_test 七个命令。 - set_credential/read_credential 的 provider 作用域原本硬性拒绝 LLM 账户,补上 get/set_for_llm_provider —— 编辑列表里第 3 张 LLM 卡片必须能按 id 定位。 - validate_provider_credentials / list_provider_models 新增可选 channel_id: 卡片上的「测试连通」要测用户点的那一张,而不是当前生效的那张。 前端 - ChannelList:卡片列表、拖拽排序、开关沉底、生效中/失败标红/延迟展示。 - 添加与编辑弹窗;本地引擎与 Codex OAuth 不做预置固定卡片,和云端厂商一样从 「添加渠道」里选,只是编辑时没有 key/地址字段。 - 新手引导(Onboarding)列表为空时直接摊开添加表单,不让新用户对着空列表发呆。 - 五种语言文案齐备。 验证:cargo test --lib persistence::credentials(31 passed)、commands::(104 passed)、 npm test(含 tsc + vite build,退出码 0)、浏览器 mock 预览逐项走查四种卡片状态与两个弹窗。 设计与分期见 docs/provider-channels-plan.md。 Co-Authored-By: Claude Opus 5 --- docs/provider-channels-plan.md | 158 +++ .../app/src-tauri/src/commands/channels.rs | 124 ++ .../app/src-tauri/src/commands/credentials.rs | 57 +- .../app/src-tauri/src/commands/mod.rs | 6 +- .../app/src-tauri/src/commands/providers.rs | 210 ++-- openless-all/app/src-tauri/src/lib.rs | 14 + .../src-tauri/src/persistence/credentials.rs | 1028 ++++++++++++++++- .../app/src/components/Onboarding.tsx | 6 +- openless-all/app/src/i18n/en.ts | 23 + openless-all/app/src/i18n/ja.ts | 23 + openless-all/app/src/i18n/ko.ts | 23 + openless-all/app/src/i18n/zh-CN.ts | 23 + openless-all/app/src/i18n/zh-TW.ts | 23 + .../app/src/lib/ipc/asr-credentials.ts | 7 +- openless-all/app/src/lib/ipc/channels.ts | 142 +++ openless-all/app/src/lib/ipc/index.ts | 12 + .../app/src/pages/settings/ChannelList.tsx | 558 +++++++++ .../src/pages/settings/ProvidersSection.tsx | 618 ++++------ openless-all/app/src/pages/settings/tabs.tsx | 2 +- 19 files changed, 2546 insertions(+), 511 deletions(-) create mode 100644 docs/provider-channels-plan.md create mode 100644 openless-all/app/src-tauri/src/commands/channels.rs create mode 100644 openless-all/app/src/lib/ipc/channels.ts create mode 100644 openless-all/app/src/pages/settings/ChannelList.tsx diff --git a/docs/provider-channels-plan.md b/docs/provider-channels-plan.md new file mode 100644 index 000000000..47f9cfd78 --- /dev/null +++ b/docs/provider-channels-plan.md @@ -0,0 +1,158 @@ +# 供应商渠道卡片化 实施计划 + +> 状态:设计讨论中(尚未动手) +> 日期: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. 重试策略 + +照搬 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..c932ca9c4 --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/channels.rs @@ -0,0 +1,124 @@ +//! 渠道卡片管理的 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 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 5030e60e9..78035f266 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -205,25 +205,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 { @@ -330,7 +319,14 @@ 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()) } @@ -339,7 +335,28 @@ 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, + } +} + +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 537c304a9..a37e15107 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -2,6 +2,60 @@ 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 +67,20 @@ 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 { + 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 }), _ => Err(format!("unknown provider kind: {kind}")), @@ -27,14 +88,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 { @@ -56,11 +122,11 @@ 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 { @@ -126,24 +192,24 @@ fn read_openai_provider_config(kind: &str) -> Result { "asr" => ( CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint, - CredentialsVault::get_active_asr() + scope.provider_type() != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, ), _ => 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(), + scope.llm_extra_headers(), openai_compatible_temperature_for_provider( &active_llm, - CredentialsVault::get_active_llm_temperature(), + scope.llm_temperature(), ), ) } else { @@ -170,13 +236,13 @@ 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()); @@ -200,9 +266,9 @@ 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())?; @@ -246,8 +312,8 @@ fn provider_llm_error_message(error: LLMError) -> String { } } -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(()); } @@ -255,53 +321,53 @@ 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())?; @@ -314,14 +380,14 @@ 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() { @@ -346,17 +412,17 @@ 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()); @@ -380,9 +446,9 @@ 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()); @@ -397,18 +463,18 @@ 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()); @@ -443,10 +509,10 @@ 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()); @@ -454,11 +520,11 @@ 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 { @@ -472,7 +538,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 { @@ -527,8 +593,8 @@ 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() { @@ -536,7 +602,7 @@ 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()); @@ -554,11 +620,11 @@ 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( @@ -584,8 +650,8 @@ 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() { @@ -593,7 +659,7 @@ 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( @@ -601,7 +667,7 @@ 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()) @@ -609,7 +675,7 @@ 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 085ec111d..250ebc58b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -246,6 +246,13 @@ 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::delete_channel, + commands::set_channel_enabled, + commands::reorder_channels, + commands::record_channel_test, commands::get_qa_hotkey_label, commands::set_qa_hotkey, commands::set_selection_polish_hotkey, @@ -356,6 +363,13 @@ 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::delete_channel, + $crate::commands::set_channel_enabled, + $crate::commands::reorder_channels, + $crate::commands::record_channel_test, $crate::commands::validate_provider_credentials, $crate::commands::list_provider_models, $crate::commands::list_history, diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index e1d80a235..df45ac16c 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -197,9 +197,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")] @@ -235,6 +297,12 @@ struct CredsAsrEntry { impl CredsAsrEntry { fn is_empty(&self) -> bool { + // 渠道卡片(providerType 已写入)永远不算空:用户可能刚点「添加渠道」、 + // 名字都取好了还没填 key,此时被 clean_credentials 的 retain 静默删掉 + // 就是"卡片自己消失了"。渠道只能由用户显式删除。 + if self.channel.providerType.is_some() { + return false; + } self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() @@ -253,6 +321,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")] @@ -269,6 +339,10 @@ struct CredsLlmEntry { impl CredsLlmEntry { fn is_empty(&self) -> bool { + // 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。 + if self.channel.providerType.is_some() { + return false; + } self.displayName.as_deref().unwrap_or("").is_empty() && self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() @@ -282,6 +356,165 @@ impl CredsLlmEntry { } } +/// ASR / LLM 两种 entry 共享渠道元信息的读写口子,让迁移与排序逻辑只写一遍。 +trait HasChannelMeta { + fn meta(&self) -> &ChannelMeta; + fn meta_mut(&mut self) -> &mut ChannelMeta; +} + +impl HasChannelMeta for CredsAsrEntry { + fn meta(&self) -> &ChannelMeta { + &self.channel + } + fn meta_mut(&mut self) -> &mut ChannelMeta { + &mut self.channel + } +} + +impl HasChannelMeta for CredsLlmEntry { + fn meta(&self) -> &ChannelMeta { + &self.channel + } + fn meta_mut(&mut self) -> &mut ChannelMeta { + &mut self.channel + } +} + +/// 渠道的协议路由 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,所以 active 那把排最前;其余按字母序。 + keys.sort_by(|left, right| { + (left != active, left.as_str()).cmp(&(right != active, right.as_str())) + }); + + 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 @@ -973,7 +1206,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; } @@ -1016,7 +1268,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); } @@ -1054,7 +1306,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")] { @@ -1304,6 +1560,188 @@ pub struct CredentialsSnapshot { pub ark_endpoint: 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; @@ -1441,9 +1879,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<()> { @@ -1460,9 +1911,264 @@ 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) + } + + 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_llm_extra_headers() -> HashMap { @@ -1543,6 +2249,7 @@ mod tests { use super::load_android_credentials_from_source_with_crypto; use anyhow::anyhow; use parking_lot::Mutex; + use std::collections::HashMap; #[test] fn credential_payload_chunks_stay_under_windows_blob_limit() { @@ -1918,4 +2625,317 @@ 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"); + } + + #[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 5149714c1..ffdf1186d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -832,7 +832,30 @@ export const en: typeof zhCN = { startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', }, + channels: { + 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', + inUse: 'In use', + 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', + lastFailed: 'Last failed · {{when}}', + 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.', llmTitle: 'LLM (polishing)', llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', providerLabel: 'Provider', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c577a0da0..d4bd95214 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -834,7 +834,30 @@ export const ja: typeof zhCN = { startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', }, + channels: { + orderHint: 'ドラッグで並べ替え。一番上が優先で使われます。オフにしたチャネルは末尾に移動します。', + empty: 'チャネルがまだありません。下の「チャネルを追加」から作成してください。', + add: 'チャネルを追加', + edit: '編集', + inUse: '使用中', + createTitle: 'チャネルを追加', + editTitle: 'チャネルを編集', + providerLabel: 'プロバイダー', + nameLabel: '名前', + namePlaceholder: '例:SiliconFlow — メインキー', + create: '作成', + delete: 'チャネルを削除', + deleteConfirm: '削除するとこのチャネルに保存された鍵も消去されます。', + confirmDelete: '削除する', + lastFailed: '前回失敗 · {{when}}', + justNow: 'たった今', + minutesAgo: '{{count}}分前', + hoursAgo: '{{count}}時間前', + daysAgo: '{{count}}日前', + localEngineModelHint: 'ローカルエンジンのモデルは「詳細 → ローカルモデル」でダウンロード・切り替えします。', + }, providers: { + localEngineNoCredentials: 'ローカルエンジンに API キーやエンドポイントは不要です。', llmTitle: 'LLM モデル(整文)', llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', providerLabel: 'サプライヤー', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ad3139b68..773f9705e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -834,7 +834,30 @@ export const ko: typeof zhCN = { startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', }, + channels: { + orderHint: '드래그해서 순서를 바꾸세요. 맨 위가 먼저 사용됩니다. 끈 채널은 맨 아래로 내려갑니다.', + empty: '아직 채널이 없습니다. 아래 "채널 추가"로 만들어 보세요.', + add: '채널 추가', + edit: '편집', + inUse: '사용 중', + createTitle: '채널 추가', + editTitle: '채널 편집', + providerLabel: '공급자', + nameLabel: '이름', + namePlaceholder: '예: SiliconFlow — 메인 키', + create: '만들기', + delete: '채널 삭제', + deleteConfirm: '삭제하면 이 채널에 저장된 키도 함께 지워집니다.', + confirmDelete: '삭제', + lastFailed: '지난 실패 · {{when}}', + justNow: '방금', + minutesAgo: '{{count}}분 전', + hoursAgo: '{{count}}시간 전', + daysAgo: '{{count}}일 전', + localEngineModelHint: '로컬 엔진 모델은 고급 → 로컬 모델에서 내려받고 전환합니다.', + }, providers: { + localEngineNoCredentials: '로컬 엔진은 API 키나 엔드포인트가 필요 없습니다.', llmTitle: 'LLM 모델(정리)', llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', providerLabel: '공급자', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 5086e86b8..95d79b16a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -830,7 +830,30 @@ export const zhCN = { startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', }, + channels: { + orderHint: '拖动排序,最上面的优先使用;关掉的渠道会自动排到末尾。', + empty: '还没有渠道。点下面的「添加渠道」新建一个。', + add: '添加渠道', + edit: '编辑', + inUse: '生效中', + createTitle: '添加渠道', + editTitle: '编辑渠道', + providerLabel: '供应商', + nameLabel: '名称', + namePlaceholder: '例如:硅基流动-主号', + create: '创建', + delete: '删除渠道', + deleteConfirm: '删除后该渠道保存的密钥也会一并清除。', + confirmDelete: '确认删除', + lastFailed: '上次失败 · {{when}}', + justNow: '刚刚', + minutesAgo: '{{count}} 分钟前', + hoursAgo: '{{count}} 小时前', + daysAgo: '{{count}} 天前', + localEngineModelHint: '本地引擎的模型下载与切换在「高级 → 本地模型」里。', + }, providers: { + localEngineNoCredentials: '本地引擎无需 API Key 与地址。', llmTitle: 'LLM 模型(润色)', llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', providerLabel: '供应商', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9d18afe58..8292dc5b2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -832,7 +832,30 @@ export const zhTW: typeof zhCN = { startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', }, + channels: { + orderHint: '拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。', + empty: '還沒有渠道。點下面的「新增渠道」建立一個。', + add: '新增渠道', + edit: '編輯', + inUse: '生效中', + createTitle: '新增渠道', + editTitle: '編輯渠道', + providerLabel: '供應商', + nameLabel: '名稱', + namePlaceholder: '例如:矽基流動-主帳號', + create: '建立', + delete: '刪除渠道', + deleteConfirm: '刪除後該渠道儲存的金鑰也會一併清除。', + confirmDelete: '確認刪除', + lastFailed: '上次失敗 · {{when}}', + justNow: '剛剛', + minutesAgo: '{{count}} 分鐘前', + hoursAgo: '{{count}} 小時前', + daysAgo: '{{count}} 天前', + localEngineModelHint: '本機引擎的模型下載與切換在「進階 → 本機模型」裡。', + }, providers: { + localEngineNoCredentials: '本機引擎不需要 API Key 與網址。', llmTitle: 'LLM 模型(潤色)', llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', providerLabel: '供應商', diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 49ebe0ca9..53f4ca014 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -46,18 +46,21 @@ export function readCredential(account: string, provider?: string): Promise { - return invokeOrMock("validate_provider_credentials", { kind }, () => ({ + return invokeOrMock("validate_provider_credentials", { kind, channelId }, () => ({ ok: true, })) } export function listProviderModels( kind: "llm" | "asr", + channelId?: string, ): Promise { - return invokeOrMock("list_provider_models", { kind }, () => ({ + return invokeOrMock("list_provider_models", { kind, channelId }, () => ({ models: kind === "llm" ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] 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..6761fa06e --- /dev/null +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -0,0 +1,142 @@ +// 渠道卡片的 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: "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 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 }, () => 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 b92aa5683..cf792c2d3 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -32,6 +32,18 @@ export { listProviderModels, } from "./asr-credentials" +// channels(渠道卡片) +export type { Channel, ChannelKind, ChannelTestResult } from "./channels" +export { + listChannels, + createChannel, + 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..fcf30a456 --- /dev/null +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -0,0 +1,558 @@ +// 渠道卡片列表 —— 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, + listChannels, + readCredential, + renameChannel, + reorderChannels, + setChannelEnabled, + type Channel, +} from '../../lib/ipc'; +import { emitSaved } from '../../lib/savedEvent'; +import { useMobileLayout } from '../../lib/useMobileLayout'; +import { Card } from '../_atoms'; +import { ChannelCredentialFields, LLM_PRESETS, LOCAL_ASR_PROVIDER_IDS } 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'; +} + +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 [channels, setChannels] = useState([]); + const [models, setModels] = useState>({}); + const [loaded, setLoaded] = useState(false); + const [editingId, setEditingId] = useState(null); + const [creating, setCreating] = useState(false); + const [dragId, setDragId] = useState(null); + // 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。 + 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]); + + useEffect(() => { + if (!autoCreateWhenEmpty || !loaded || autoOpenedRef.current) return; + if (channels.length === 0) { + autoOpenedRef.current = true; + setCreating(true); + } + }, [autoCreateWhenEmpty, loaded, channels.length]); + + // 生效中的那张 = 第一个启用的(列表已按 order 排好)。 + const activeId = channels.find(c => c.enabled)?.id ?? null; + + 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')); + } + }; + + const onDrop = async (targetId: string) => { + if (!dragId || dragId === targetId) { + setDragId(null); + return; + } + const ids = channels.map(c => c.id); + const from = ids.indexOf(dragId); + const to = ids.indexOf(targetId); + setDragId(null); + if (from < 0 || to < 0) return; + ids.splice(to, 0, ids.splice(from, 1)[0]); + // 乐观更新:先按新顺序重排本地列表,避免拖完到刷新之间卡片跳回原位。 + setChannels(prev => ids.map(id => prev.find(c => c.id === id)!).filter(Boolean)); + 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(); + } + }; + + const editing = channels.find(c => c.id === editingId) ?? null; + + 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 ( +
setDragId(channel.id)} + onDragOver={e => e.preventDefault()} + onDrop={() => void onDrop(channel.id)} + onDragEnd={() => setDragId(null)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '10px 12px', + borderRadius: 10, + border: `0.5px solid ${isActive ? 'var(--ol-blue)' : 'var(--ol-line-strong)'}`, + background: channel.enabled ? 'var(--ol-surface)' : 'var(--ol-bg-2, transparent)', + opacity: dragId === channel.id ? 0.5 : channel.enabled ? 1 : 0.62, + cursor: 'grab', + }} + > + + +
+
+ {label} + {isActive && ( + + {t('settings.channels.inUse')} + + )} +
+
+ {/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */} + {channel.name.trim() && {presetLabel(kind, channel.providerType, t)}} + {model && {model}} + {channel.lastTest?.ok && channel.lastTest.latencyMs != null && ( + {channel.lastTest.latencyMs}ms + )} + {failed && ( + + {t('settings.channels.lastFailed', { + when: relativeTime(channel.lastTest!.at, t), + })} + + )} +
+
+ void onToggle(channel)} /> + +
+ ); + })} +
+ + + + {creating && ( + setCreating(false)} + onCreated={async id => { + setCreating(false); + await refresh(); + setEditingId(id); + }} + /> + )} + + {editing && ( + { + setEditingId(null); + void refresh(); + }} + onChanged={refresh} + /> + )} +
+ ); +} + +/** + * 「服务 → AI 提供商」面板:LLM 与 ASR 两张渠道列表。 + * + * 保留 `ProvidersSection` 这个名字与 `kind` 签名,让设置页 tabs 与新手引导的调用点 + * 不用改。渠道化之后它只是两个 的容器。 + */ +export function ProvidersSection({ + kind = 'all', + autoCreateWhenEmpty = false, +}: { + kind?: 'all' | 'llm' | 'asr'; + autoCreateWhenEmpty?: boolean; +} = {}) { + const { t } = useTranslation(); + return ( + <> + {kind === 'all' && ( +
+ {t('settings.providers.credentialStorageNotice')} +
+ )} + {(kind === 'all' || kind === 'llm') && ( + + )} + {(kind === 'all' || kind === 'asr') && ( + + )} + + ); +} + +/** 新建:先定名字与供应商,创建拿到 id 之后才谈得上填凭据(凭据按渠道 id 作用域存)。 */ +function ChannelCreateModal({ + kind, + presets, + onClose, + onCreated, +}: { + kind: ChannelKind; + presets: PresetOption[]; + onClose: () => void; + onCreated: (id: string) => void | Promise; +}) { + const { t } = useTranslation(); + const [providerType, setProviderType] = useState(presets[0]?.id ?? ''); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (!providerType || busy) return; + setBusy(true); + try { + const id = await createChannel(kind, providerType, name.trim()); + await onCreated(id); + } catch (error) { + console.error('[channels] create failed', error); + emitSaved('failed', t('common.operationFailed')); + setBusy(false); + } + }; + + return ( + +
+ {t('settings.channels.createTitle')} +
+ + ({ + 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)} + placeholder={t('settings.channels.namePlaceholder')} + onKeyDown={e => { + if (e.key === 'Enter') void submit(); + }} + style={{ ...inputStyle, width: '100%', marginBottom: 18 }} + /> +
+ + +
+
+ ); +} + +function ChannelEditModal({ + kind, + channel, + mobile, + onClose, + onChanged, +}: { + kind: ChannelKind; + channel: Channel; + mobile: boolean; + onClose: () => void; + onChanged: () => void | Promise; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(channel.name); + const [confirmDelete, setConfirmDelete] = useState(false); + + const saveName = async () => { + if (name === channel.name) 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 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(channel.providerType); + + return ( + +
+ {t('settings.channels.editTitle')} +
+
+ {presetLabel(kind, channel.providerType, t)} +
+ + + setName(e.target.value)} + onBlur={() => void saveName()} + placeholder={t('settings.channels.namePlaceholder')} + style={{ ...inputStyle, width: '100%', marginBottom: 14 }} + /> + + 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 40ca6caed..d4103129e 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, setCredential, validateProviderCredentials, } from '../../lib/ipc'; @@ -170,6 +169,14 @@ type LlmPresetId = typeof LLM_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 的注释。 @@ -206,136 +213,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 [committedLlmProvider, setCommittedLlmProvider] = useState('ark'); - const [committedAsrProvider, setCommittedAsrProvider] = useState('volcengine'); - const llmSwitchSeqRef = useRef(0); - const asrSwitchSeqRef = useRef(0); const [llmModelRevision, setLlmModelRevision] = useState(0); const [asrModelRevision, setAsrModelRevision] = 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)仍只在「高级 → 本地模型」里启用, - // 防止新手在主下拉误开 CPU 推理。Apple 语音是系统自带、零凭据、轻量, - // 在 macOS 上直接作为常规选项放进主下拉,方便随时选用 / 切走。 - const visibleAsrPresets = ASR_PRESETS.filter( - p => p.id !== 'foundry-local-whisper' - && p.id !== 'local-qwen3' - && p.id !== 'sherpa-onnx-local' - && (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); - }, [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 showLlm = kind === 'all' || kind === 'llm'; - const showAsr = kind === 'all' || kind === 'asr'; - return ( - <> - {kind === 'all' && ( -
- {t('settings.providers.credentialStorageNotice')} -
- )} - {showLlm && ( - -
- {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={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} - /> - + 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 && ( - -
- {t('settings.providers.asrTitle')} -
- {/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示, - 未激活时不显示提示。 */} - - {(() => { - // 本地引擎激活时不再「接管 / 锁死」下拉——下拉始终可用,用户在本页就能直接 - // 切到其它供应商;切走后端 active 即自动停用本地引擎,不必再进「高级」手动关。 - // 重引擎(qwen3 / sherpa / foundry)当前激活但不在主下拉里时,补一个可选 option - // 让 select 显示当前值并允许切走。Apple 语音在 macOS 已是常规可选项。 - const hiddenLocalActive: AsrPresetId | null = - !visibleAsrPresets.some(p => p.id === committedAsrProvider) - ? committedAsrProvider - : null; - const hiddenLocalNameKey = hiddenLocalActive === 'local-qwen3' - ? 'asrLocalQwen3' - : hiddenLocalActive === 'foundry-local-whisper' - ? 'asrFoundryLocalWhisper' - : hiddenLocalActive === 'sherpa-onnx-local' - ? 'asrSherpaOnnxLocal' - : hiddenLocalActive === 'apple-speech' - ? 'asrAppleSpeech' - : null; - return ( -
- onAsrProviderChange(next as AsrPresetId)} - options={[ - ...visibleAsrPresets.map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - })), - ...(hiddenLocalActive && hiddenLocalNameKey - ? [{ - value: hiddenLocalActive, - label: t(`settings.providers.presets.${hiddenLocalNameKey}`), - }] - : []), - ]} - ariaLabel={t('settings.providers.providerLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }} - /> - {hiddenLocalActive && ( -
- {t('settings.providers.asrProviderTakenOver')} -
- )} -
- ); - })()} + 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')); + } + }} + 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 }} + /> - {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={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 260 }} - /> - - {/* 两种模式使用各自独立的凭据槽位:旧版 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' ? ( - // 用户已经在用本地 ASR——dropdown 行的 asrProviderTakenOver 已经把 - // "在高级中切换或禁用"讲清楚了,body 不再重复。 - // 模型管理 UI 唯一入口在「高级 → 本地模型」里的 。 - null ) : ( - <> - - {/* 统一百炼保留 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')} +
+ setAsrModelRevision(v => v + 1)} onTested={onTested} /> + + ); + } + + if (providerType === 'iflytek') { + return ( + <> + + +
+ {t('settings.providers.xfyunNote')} +
+ 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) && ( + <> + +
+ {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') && ( + )} ); @@ -863,7 +649,7 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr'; modelAccount: string; provider?: string; onModelSelected: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, showFetchModels = true }: { kind: 'llm' | 'asr'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const mobile = useMobileLayout(); const [models, setModels] = useState([]); @@ -876,34 +662,52 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetc setMessage(nextMessage); }; + // 把测试结果落到渠道上(卡片据此显示延迟或标红)。失败不打断主流程: + // 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。 + const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => { + if (!provider) 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')); diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 03705cdb7..1dd82b39b 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'; From 77416b8855d15ba5b374582d38faa963ba3be02d Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 4 Aug 2026 23:38:46 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(channels):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=94=B9=E6=88=90=E4=B8=80=E6=AD=A5=E3=80=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E5=9C=A8=E7=9C=9F=E6=9C=BA=E8=83=BD=E7=94=A8=E3=80=81=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E7=8A=B6=E6=80=81=E4=B8=8D=E5=86=8D=E5=81=87=E8=A3=85?= =?UTF-8?q?"=E5=81=A5=E5=BA=B7"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处都是装机自用后暴露的问题。 **添加渠道少一步。** 原来是「先建卡片、再填凭据」两步 —— 那只是实现上需要先有渠道 id 才能写凭据(凭据按 id 作用域存),不该变成用户多点一次。改成点+ 直接开一个完整弹窗, 供应商、名字、密钥、地址、模型、连接检查都在一屏里;草稿卡片在后台先建出来,用户什么 都没填就关掉时由 delete_channel_if_blank 回收,不会在列表里留空卡片。换供应商也不再 需要重建卡片,走新增的 set_channel_provider_type。 **拖拽在打包后的 app 里根本不动。** Tauri 的 webview 默认开着 dragDropEnabled,会把 HTML5 的 dragstart/drop 当成文件拖放吞掉 —— 浏览器预览里是好的,真机里是坏的,只验 前者就会漏。改用 pointer 事件手写,顺带让 Windows / Android 行为一致。 改的过程中炸出第二个:最初用 setPointerCapture,它把后续事件重定向到手柄,浏览器补发 的 click 于是落到设置弹窗的遮罩上(遮罩挂着 onClick={onClose}),**拖一下卡片整个设置 面板就关了**。改成 window 级监听不动事件目标,并在捕获阶段吞掉拖拽后紧跟的那一次 click。 **卡片状态不再假装"健康"。** 原来当前那张有绿点 + 「生效中」文字,两者都被读成"这张 能用",可它只代表排在最前面 —— 一张 key 已经失效的卡片照样排第一。优先级和健康度是两 个正交的维度,被压成了一个视觉。现在: - 绿点与「生效中」全部去掉,当前那张只用左侧一条竖条表达位置,不带健康暗示; - 验证按钮移到卡片上(开关左侧),**按钮自己就是结果容器**:未验过显示「验证」、 通过显示延迟数字(`284ms`,数字本身既说明通了又能比快慢)、失败显示能指导行动的短 标签(`✗ 401` 改 key / `✗ 429` 等会儿 / `✗ 超时` 查网络); - 副行显示上次验证是多久以前,超过一天的结果褪色 —— 让"这条结论会过期"可见; - 按钮宽度固定,避免文字变化把开关和箭头挤来挤去; - **不做自动验证**:验证是真实 API 调用(LLM 走一次真润色、ASR 传一段静音音频), 打开设置就全部验一遍等于按卡片数烧额度,还容易撞进限流。 顺带把 mock 的 reorderChannels 改成真重排:原来是空操作,浏览器预览里松手后顺序被 listChannels 拉回原样,看着像"拖拽坏了",会误导下一个人。 另:补上此前遗漏的 rustfmt(自己编辑范围内的行)。 验证:cargo test --lib persistence::credentials(31 passed)、commands::(101 passed)、 tsc 0;浏览器侧用精确的 pointer 事件序列验了拖动跟手、松手持久化、设置面板不被误关。 Co-Authored-By: Claude Opus 5 --- .../app/src-tauri/src/commands/channels.rs | 33 ++ .../app/src-tauri/src/commands/credentials.rs | 13 +- .../app/src-tauri/src/commands/providers.rs | 123 ++-- openless-all/app/src-tauri/src/lib.rs | 4 + .../src-tauri/src/persistence/credentials.rs | 183 ++++-- openless-all/app/src/i18n/en.ts | 9 +- openless-all/app/src/i18n/ja.ts | 9 +- openless-all/app/src/i18n/ko.ts | 9 +- openless-all/app/src/i18n/zh-CN.ts | 9 +- openless-all/app/src/i18n/zh-TW.ts | 9 +- openless-all/app/src/lib/ipc/channels.ts | 38 +- openless-all/app/src/lib/ipc/index.ts | 2 + .../app/src/pages/settings/ChannelList.tsx | 537 +++++++++++++----- 13 files changed, 727 insertions(+), 251 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/channels.rs b/openless-all/app/src-tauri/src/commands/channels.rs index c932ca9c4..c3fe40fa3 100644 --- a/openless-all/app/src-tauri/src/commands/channels.rs +++ b/openless-all/app/src-tauri/src/commands/channels.rs @@ -39,6 +39,39 @@ pub async fn create_channel( .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, diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 78035f266..cd06fbbd0 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -200,8 +200,7 @@ pub async fn set_credential( .map_err(|e| e.to_string()); } if temperature { - return CredentialsVault::set_active_llm_temperature(&value) - .map_err(|e| e.to_string()); + return CredentialsVault::set_active_llm_temperature(&value).map_err(|e| e.to_string()); } let acc = parsed.expect("non-extra credential account must be parsed"); if let Some(provider) = provider { @@ -320,12 +319,10 @@ pub async fn read_credential( let acc = parsed.expect("non-extra credential account must be parsed"); if let Some(provider) = provider { 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()) - } + 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()) diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index a37e15107..90fa21be7 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -34,8 +34,9 @@ impl ProviderScope { /// 该渠道的厂商 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()), + (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(), } @@ -122,8 +123,7 @@ pub async fn list_provider_models( ], }); } - if kind == "asr" && scope.provider_type() == crate::asr::qwen_realtime::PROVIDER_ID - { + if kind == "asr" && scope.provider_type() == crate::asr::qwen_realtime::PROVIDER_ID { // 与 bailian 同理:Realtime 网关无模型列表接口,先做真实连通性检查, // 列表为官方文档在案的稳定别名 + 快照版本。 validate_qwen3_realtime_asr_provider(scope).await?; @@ -140,9 +140,7 @@ pub async fn list_provider_models( models: vec![crate::asr::mimo::DEFAULT_MODEL.to_string()], }); } - if kind == "asr" - && scope.provider_type() == crate::asr::dashscope_multimodal::PROVIDER_ID - { + if kind == "asr" && scope.provider_type() == crate::asr::dashscope_multimodal::PROVIDER_ID { // multimodal-generation 无模型列表 HTTP 接口;与 mimo 同,返回静态别名。 return Ok(ProviderModelsResult { models: vec![ @@ -180,7 +178,10 @@ pub(crate) struct ProviderConfig { pub(crate) temperature: Option, } -fn read_openai_provider_config(kind: &str, scope: &ProviderScope) -> 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 { @@ -192,25 +193,23 @@ fn read_openai_provider_config(kind: &str, scope: &ProviderScope) -> Result ( CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint, - scope.provider_type() - != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, + scope.provider_type() != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID, ), _ => return Err(format!("unknown provider kind: {kind}")), }; - let api_key = scope.get(api_key_account) + let api_key = scope + .get(api_key_account) .map_err(|e| e.to_string())? .unwrap_or_default(); - let base_url = scope.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 = scope.provider_type(); ( scope.llm_extra_headers(), - openai_compatible_temperature_for_provider( - &active_llm, - scope.llm_temperature(), - ), + openai_compatible_temperature_for_provider(&active_llm, scope.llm_temperature()), ) } else { (HashMap::new(), None) @@ -242,7 +241,8 @@ async fn validate_llm_provider(scope: &ProviderScope) -> Result<(), String> { .get() .llm_thinking_enabled; if scope.provider_type() == CODEX_OAUTH_PROVIDER_ID { - let model = scope.get(CredentialAccount::ArkModelId) + 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()); @@ -268,7 +268,8 @@ async fn validate_llm_provider(scope: &ProviderScope) -> Result<(), String> { let config = read_openai_provider_config("llm", scope)?; let active_llm = scope.provider_type(); - let model = scope.get(CredentialAccount::ArkModelId) + let model = scope + .get(CredentialAccount::ArkModelId) .map_err(|e| e.to_string())? .filter(|s| !s.is_empty()) .ok_or_else(|| "llmModelMissing".to_string())?; @@ -321,7 +322,8 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { if active_asr == crate::asr::bailian::PROVIDER_ID { // 统一百炼:按所选模型验证对应协议(endpoint 由前端按模型同步,各 validator // 读到的都是该协议的正确地址)。 - let model = scope.get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .ok() .flatten() .unwrap_or_default(); @@ -341,7 +343,8 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { return validate_mimo_asr_provider(scope).await; } if active_asr == crate::asr::dashscope_multimodal::PROVIDER_ID { - let model = scope.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)?; @@ -356,7 +359,8 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { // StepFun 一入口双协议:`*-stream` 模型走实时 WS 验证,其余走批式 // /audio/transcriptions(与 build 侧 resolve_effective_asr_provider 同判据)。 if active_asr == "stepfun" || active_asr == crate::asr::stepfun_realtime::PROVIDER_ID { - let model = scope.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 @@ -367,7 +371,8 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { } let config = read_openai_provider_config("asr", scope)?; - let model = scope.get(CredentialAccount::AsrModel) + let model = scope + .get(CredentialAccount::AsrModel) .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .ok_or_else(|| "asrModelMissing".to_string())?; @@ -381,13 +386,15 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> { /// 即返回;纯静音会话服务端可能直接关闭且不返回任何 result(等价于「没说话」), /// 这类 `NoFinalResult` 不算验证失败 —— 握手成功已经证明 AppID/APIKey 有效。 async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String> { - let app_id = scope.get(CredentialAccount::XfyunAppId) + 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 = scope.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() { @@ -413,16 +420,19 @@ async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String /// 协议无 finish 事件,收尾走静音帧 + 宽限期(纯静音会话以空文本成功返回, /// 见 stepfun_realtime 模块注释),全程 ~2s。 async fn validate_stepfun_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> { - let api_key = scope.get(CredentialAccount::AsrApiKey) + 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 = scope.get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); - let model = scope.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()); @@ -448,7 +458,8 @@ async fn validate_stepfun_realtime_asr_provider(scope: &ProviderScope) -> Result async fn validate_mimo_asr_provider(scope: &ProviderScope) -> Result<(), String> { let config = read_openai_provider_config("asr", scope)?; - let model = scope.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::mimo::DEFAULT_MODEL.to_string()); @@ -464,17 +475,20 @@ async fn validate_mimo_asr_provider(scope: &ProviderScope) -> Result<(), String> } async fn validate_elevenlabs_asr_provider(scope: &ProviderScope) -> Result<(), String> { - let api_key = scope.get(CredentialAccount::AsrApiKey) + 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 = scope.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 = scope.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()); @@ -512,7 +526,8 @@ const DASHSCOPE_ASR_VALIDATE_POLL_SECS: u64 = 60; async fn validate_dashscope_multimodal_asr_provider(scope: &ProviderScope) -> Result<(), String> { // 统一百炼复用配置中的区域/工作空间主机,并推导 multimodal 的 https 路径。 // 隐藏别名仍按原有完整 endpoint 读取。 - let model = scope.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()); @@ -520,11 +535,13 @@ async fn validate_dashscope_multimodal_asr_provider(scope: &ProviderScope) -> Re 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 = scope.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 = scope.get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); let endpoint_protocol = match protocol { @@ -594,7 +611,8 @@ async fn send_dashscope_multimodal_validation( } async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), String> { - let api_key = scope.get(CredentialAccount::AsrApiKey) + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { @@ -602,7 +620,8 @@ async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), Stri } // 已知残留(issue #609 F-01 孪生 gap):Bailian endpoint 走 `wss://`,与 http/https-only 的 // validate_http_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。 - let stored_endpoint = scope.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()); @@ -620,11 +639,13 @@ async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), Stri if !crate::asr::bailian::endpoint_scheme_is_websocket(&endpoint) { return Err("bailianEndpointSchemeInvalid".to_string()); } - let model = scope.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 = scope.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( @@ -651,7 +672,8 @@ async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), Stri } async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> { - let api_key = scope.get(CredentialAccount::AsrApiKey) + let api_key = scope + .get(CredentialAccount::AsrApiKey) .map_err(|e| e.to_string())? .unwrap_or_default(); if api_key.trim().is_empty() { @@ -659,7 +681,8 @@ async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<( } // 统一百炼保留配置中的区域/工作空间主机,并切换到 Qwen Realtime 路径。 let endpoint = if crate::coordinator::unified_bailian_is_active() { - let endpoint = scope.get(CredentialAccount::AsrEndpoint) + let endpoint = scope + .get(CredentialAccount::AsrEndpoint) .map_err(|e| e.to_string())? .unwrap_or_default(); crate::coordinator::derive_bailian_endpoint( @@ -667,7 +690,8 @@ async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<( crate::coordinator::BailianEndpointProtocol::QwenRealtime, )? } else { - scope.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()) @@ -675,7 +699,8 @@ async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<( if !crate::asr::qwen_realtime::endpoint_scheme_is_secure_websocket(&endpoint) { return Err("qwen3EndpointSchemeInvalid".to_string()); } - let model = scope.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()); @@ -1307,9 +1332,12 @@ mod tests { stream.write_all(response.as_bytes()).await.unwrap(); }); let target_server = tokio::spawn(async move { - tokio::time::timeout(std::time::Duration::from_millis(500), target_listener.accept()) - .await - .is_ok() + tokio::time::timeout( + std::time::Duration::from_millis(500), + target_listener.accept(), + ) + .await + .is_ok() }); let error = send_dashscope_multimodal_validation( @@ -1322,7 +1350,10 @@ mod tests { redirect_server.await.unwrap(); assert_eq!(error, "providerHttpStatus:302"); - assert!(!target_server.await.unwrap(), "validation followed redirect"); + assert!( + !target_server.await.unwrap(), + "validation followed redirect" + ); } #[test] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 250ebc58b..92b9517e2 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -249,6 +249,8 @@ macro_rules! app_invoke_handler_desktop { 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, @@ -366,6 +368,8 @@ macro_rules! app_invoke_handler_mobile { $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, diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index df45ac16c..3826c1ea1 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -299,11 +299,18 @@ impl CredsAsrEntry { fn is_empty(&self) -> bool { // 渠道卡片(providerType 已写入)永远不算空:用户可能刚点「添加渠道」、 // 名字都取好了还没填 key,此时被 clean_credentials 的 retain 静默删掉 - // 就是"卡片自己消失了"。渠道只能由用户显式删除。 + // 就是"卡片自己消失了"。渠道只能由用户显式删除(或由 + // `delete_channel_if_blank` 回收一张什么都没填的草稿)。 if self.channel.providerType.is_some() { return false; } - self.apiKey.as_deref().unwrap_or("").is_empty() + 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() @@ -343,6 +350,11 @@ impl CredsLlmEntry { 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() @@ -413,7 +425,11 @@ fn current_channel_id(map: &HashMap) -> Option(map: &mut HashMap, active: &str) -> bool { - if map.is_empty() || map.values().all(|entry| entry.meta().providerType.is_some()) { + if map.is_empty() + || map + .values() + .all(|entry| entry.meta().providerType.is_some()) + { return false; } @@ -773,13 +789,13 @@ fn load_android_credentials_from_source_with_crypto( ReadOutcome::Legacy(bytes) => (bytes, true), ReadOutcome::Plaintext(bytes) => (bytes, false), }; - let root = serde_json::from_slice::(&bytes) - .context("parse Android credential payload")?; + let root = + serde_json::from_slice::(&bytes).context("parse Android credential payload")?; let cleaned = android_persistable_credentials(&root); let contained_marketplace_token = lookup_marketplace_github_token(&root).is_some(); if needs_rewrite && contained_marketplace_token { - let sanitized = serde_json::to_vec(&cleaned) - .context("encode bearer-free Android legacy payload")?; + let sanitized = + serde_json::to_vec(&cleaned).context("encode bearer-free Android legacy payload")?; super::android_credentials::rewrite_legacy_without_bearer(source_path, &sanitized) .map_err(anyhow::Error::new) .context("scrub Marketplace bearer before Android Keystore migration")?; @@ -1991,6 +2007,81 @@ impl CredentialsVault { 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()?; @@ -2195,7 +2286,11 @@ impl CredentialsVault { let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; let mut root = load_credentials_for_update()?; - let entry = root.providers.llm.entry(root.active.llm.clone()).or_default(); + let entry = root + .providers + .llm + .entry(root.active.llm.clone()) + .or_default(); entry.temperature = temperature; save_credentials(&root) } @@ -2204,7 +2299,11 @@ impl CredentialsVault { let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; let mut root = load_credentials_for_update()?; - let entry = root.providers.llm.entry(root.active.llm.clone()).or_default(); + let entry = root + .providers + .llm + .entry(root.active.llm.clone()) + .or_default(); entry.extraHeaders = if headers.is_empty() { None } else { @@ -2236,6 +2335,8 @@ impl CredentialsVault { #[cfg(test)] mod tests { + #[cfg(not(windows))] + use super::load_android_credentials_from_source_with_crypto; use super::{ android_persistable_credentials, chunk_json_payload, credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path, @@ -2245,8 +2346,6 @@ mod tests { write_marketplace_github_token, CredentialAccount, CredsAsrEntry, CredsRoot, MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS, }; - #[cfg(not(windows))] - use super::load_android_credentials_from_source_with_crypto; use anyhow::anyhow; use parking_lot::Mutex; use std::collections::HashMap; @@ -2316,8 +2415,13 @@ mod tests { // 清空即移除该字段,且只影响对应 provider 的 entry。 write_account(&mut root, CredentialAccount::AsrAdvancedConfig, None); - assert_eq!(lookup_account(&root, CredentialAccount::AsrAdvancedConfig), None); - assert!(root.providers.asr["openai-compatible"].advancedConfig.is_none()); + assert_eq!( + lookup_account(&root, CredentialAccount::AsrAdvancedConfig), + None + ); + assert!(root.providers.asr["openai-compatible"] + .advancedConfig + .is_none()); // 旧条目(无 advancedConfig 字段)反序列化为 None,不破坏既有数据。 let legacy: CredsAsrEntry = serde_json::from_str(r#"{"apiKey":"k"}"#).unwrap(); @@ -2443,9 +2547,11 @@ mod tests { assert!(std::fs::read_to_string(&destination_path) .unwrap() .contains("openless-android-credentials")); - assert!(load_android_credentials_from_path_with_crypto(&destination_path, &mut crypto) - .unwrap() - .is_some()); + assert!( + load_android_credentials_from_path_with_crypto(&destination_path, &mut crypto) + .unwrap() + .is_some() + ); std::fs::remove_dir_all(root_dir).unwrap(); } @@ -2507,9 +2613,8 @@ mod tests { ) .unwrap(); let mut crypto = super::super::android_credentials::TestCrypto::default(); - crypto.fail_next_seal = Some( - super::super::android_credentials::CryptoErrorKind::TemporarilyUnavailable, - ); + crypto.fail_next_seal = + Some(super::super::android_credentials::CryptoErrorKind::TemporarilyUnavailable); assert!(load_android_credentials_from_path_with_crypto(&path, &mut crypto).is_err()); let sanitized = std::fs::read(&path).unwrap(); @@ -2654,10 +2759,17 @@ mod tests { assert!(super::migrate_channels(&mut root)); // id 沿用原 preset id —— 老用户的 map key 一个字节都不变。 - let volcengine = root.providers.asr.get("volcengine").expect("volcengine kept"); + 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!( + volcengine.channel.providerType.as_deref(), + Some("volcengine") + ); assert_eq!(groq.channel.providerType.as_deref(), Some("groq")); // 原 active 排第一。 assert_eq!(volcengine.channel.order, Some(0)); @@ -2763,7 +2875,12 @@ mod tests { }"#; 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(), + root.providers + .asr + .get("volcengine") + .unwrap() + .appKey + .as_deref(), Some("vk") ); // 缺省即启用,且尚未渠道化。 @@ -2771,7 +2888,10 @@ mod tests { assert!(entry.channel.enabled); assert_eq!(entry.channel.providerType, None); // 未迁移时 providerType 回落到 map key。 - assert_eq!(super::channel_provider_type("volcengine", entry), "volcengine"); + assert_eq!( + super::channel_provider_type("volcengine", entry), + "volcengine" + ); } // ---- 排序 / 开关 ---- @@ -2822,11 +2942,7 @@ mod tests { assert_eq!( ordered(&map), - vec![ - ("b".into(), true), - ("c".into(), true), - ("a".into(), false), - ] + vec![("b".into(), true), ("c".into(), true), ("a".into(), false),] ); } @@ -2850,11 +2966,7 @@ mod tests { assert_eq!( ordered(&map), - vec![ - ("c".into(), true), - ("b".into(), true), - ("a".into(), false), - ] + vec![("c".into(), true), ("b".into(), true), ("a".into(), false),] ); // order 压实成 0..n,避免反复拖拽后数值发散。 let mut orders: Vec = map @@ -2932,7 +3044,10 @@ mod tests { 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!( + 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/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ffdf1186d..377a71cc7 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -833,11 +833,17 @@ export const en: typeof zhCN = { 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', - inUse: 'In use', createTitle: 'Add channel', editTitle: 'Edit channel', providerLabel: 'Provider', @@ -847,7 +853,6 @@ export const en: typeof zhCN = { delete: 'Delete channel', deleteConfirm: 'Deleting also clears the keys stored for this channel.', confirmDelete: 'Delete', - lastFailed: 'Last failed · {{when}}', justNow: 'just now', minutesAgo: '{{count}}m ago', hoursAgo: '{{count}}h ago', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d4bd95214..1032d85a7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -835,11 +835,17 @@ export const ja: typeof zhCN = { startupAtBootError: '自動起動の切り替えに失敗:{{message}}', }, channels: { + verify: '検証', + verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します', + errTimeout: 'タイムアウト', + errNetwork: 'ネットワーク', + errEndpoint: 'エンドポイント', + errGeneric: '失敗', + dragHint: 'ドラッグで優先順位を変更', orderHint: 'ドラッグで並べ替え。一番上が優先で使われます。オフにしたチャネルは末尾に移動します。', empty: 'チャネルがまだありません。下の「チャネルを追加」から作成してください。', add: 'チャネルを追加', edit: '編集', - inUse: '使用中', createTitle: 'チャネルを追加', editTitle: 'チャネルを編集', providerLabel: 'プロバイダー', @@ -849,7 +855,6 @@ export const ja: typeof zhCN = { delete: 'チャネルを削除', deleteConfirm: '削除するとこのチャネルに保存された鍵も消去されます。', confirmDelete: '削除する', - lastFailed: '前回失敗 · {{when}}', justNow: 'たった今', minutesAgo: '{{count}}分前', hoursAgo: '{{count}}時間前', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 773f9705e..7d1f8f320 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -835,11 +835,17 @@ export const ko: typeof zhCN = { startupAtBootError: '자동 시작 전환 실패: {{message}}', }, channels: { + verify: '검증', + verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다', + errTimeout: '시간 초과', + errNetwork: '네트워크', + errEndpoint: '주소', + errGeneric: '실패', + dragHint: '드래그해서 우선순위 변경', orderHint: '드래그해서 순서를 바꾸세요. 맨 위가 먼저 사용됩니다. 끈 채널은 맨 아래로 내려갑니다.', empty: '아직 채널이 없습니다. 아래 "채널 추가"로 만들어 보세요.', add: '채널 추가', edit: '편집', - inUse: '사용 중', createTitle: '채널 추가', editTitle: '채널 편집', providerLabel: '공급자', @@ -849,7 +855,6 @@ export const ko: typeof zhCN = { delete: '채널 삭제', deleteConfirm: '삭제하면 이 채널에 저장된 키도 함께 지워집니다.', confirmDelete: '삭제', - lastFailed: '지난 실패 · {{when}}', justNow: '방금', minutesAgo: '{{count}}분 전', hoursAgo: '{{count}}시간 전', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 95d79b16a..ad58d6fdf 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -831,11 +831,17 @@ export const zhCN = { startupAtBootError: '开机自启切换失败:{{message}}', }, channels: { + verify: '验证', + verifyHint: '点一下真实调用一次接口,确认这张卡现在能用', + errTimeout: '超时', + errNetwork: '网络', + errEndpoint: '地址', + errGeneric: '失败', + dragHint: '按住拖动可调整优先级', orderHint: '拖动排序,最上面的优先使用;关掉的渠道会自动排到末尾。', empty: '还没有渠道。点下面的「添加渠道」新建一个。', add: '添加渠道', edit: '编辑', - inUse: '生效中', createTitle: '添加渠道', editTitle: '编辑渠道', providerLabel: '供应商', @@ -845,7 +851,6 @@ export const zhCN = { delete: '删除渠道', deleteConfirm: '删除后该渠道保存的密钥也会一并清除。', confirmDelete: '确认删除', - lastFailed: '上次失败 · {{when}}', justNow: '刚刚', minutesAgo: '{{count}} 分钟前', hoursAgo: '{{count}} 小时前', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 8292dc5b2..757428513 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -833,11 +833,17 @@ export const zhTW: typeof zhCN = { startupAtBootError: '開機自啓切換失敗:{{message}}', }, channels: { + verify: '驗證', + verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用', + errTimeout: '逾時', + errNetwork: '網路', + errEndpoint: '網址', + errGeneric: '失敗', + dragHint: '按住拖曳可調整優先順序', orderHint: '拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。', empty: '還沒有渠道。點下面的「新增渠道」建立一個。', add: '新增渠道', edit: '編輯', - inUse: '生效中', createTitle: '新增渠道', editTitle: '編輯渠道', providerLabel: '供應商', @@ -847,7 +853,6 @@ export const zhTW: typeof zhCN = { delete: '刪除渠道', deleteConfirm: '刪除後該渠道儲存的金鑰也會一併清除。', confirmDelete: '確認刪除', - lastFailed: '上次失敗 · {{when}}', justNow: '剛剛', minutesAgo: '{{count}} 分鐘前', hoursAgo: '{{count}} 小時前', diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 6761fa06e..14ca64841 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -55,7 +55,7 @@ const mockChannels: Record = { providerType: "openai", enabled: false, order: 2, - lastTest: { ok: false, latencyMs: null, at: Math.floor(Date.now() / 1000) - 3600, error: "401" }, + lastTest: { ok: false, latencyMs: null, at: Math.floor(Date.now() / 1000) - 3600, error: "providerHttpStatus:401" }, }, ], asr: [ @@ -95,6 +95,27 @@ export function createChannel( ) } +/** 在已建好的草稿卡片上换供应商(单弹窗添加流程的常规操作)。 */ +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, @@ -124,7 +145,20 @@ export function reorderChannels( kind: ChannelKind, ids: string[], ): Promise { - return invokeOrMock("reorder_channels", { kind, ids }, () => undefined) + 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( diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index cf792c2d3..8c6e9981c 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -37,6 +37,8 @@ export type { Channel, ChannelKind, ChannelTestResult } from "./channels" export { listChannels, createChannel, + setChannelProviderType, + deleteChannelIfBlank, renameChannel, deleteChannel, setChannelEnabled, diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index fcf30a456..378f95542 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -16,11 +16,15 @@ 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'; @@ -69,6 +73,36 @@ 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'); @@ -90,12 +124,14 @@ export function ChannelList({ 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); - const [creating, setCreating] = useState(false); - const [dragId, setDragId] = useState(null); + /** 新建时先落一张草稿卡片(凭据必须按渠道 id 写入),弹窗直接编辑它。 */ + const [draftId, setDraftId] = useState(null); + const [creatingBusy, setCreatingBusy] = useState(false); // 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。 const autoOpenedRef = useRef(false); @@ -127,17 +163,81 @@ export function ChannelList({ 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; - setCreating(true); + void startCreate(); } - }, [autoCreateWhenEmpty, loaded, channels.length]); + }, [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 { @@ -150,19 +250,73 @@ export function ChannelList({ } }; - const onDrop = async (targetId: string) => { - if (!dragId || dragId === targetId) { - setDragId(null); - return; + // ── 拖拽排序 ── + // 用 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; // 顺序没变,不打扰后端 } - const ids = channels.map(c => c.id); - const from = ids.indexOf(dragId); - const to = ids.indexOf(targetId); - setDragId(null); - if (from < 0 || to < 0) return; - ids.splice(to, 0, ids.splice(from, 1)[0]); - // 乐观更新:先按新顺序重排本地列表,避免拖完到刷新之间卡片跳回原位。 - setChannels(prev => ids.map(id => prev.find(c => c.id === id)!).filter(Boolean)); try { await reorderChannels(kind, ids); await refresh(); @@ -174,7 +328,48 @@ export function ChannelList({ } }; - const editing = channels.find(c => c.id === editingId) ?? null; + // 刻意**不用** 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 ( @@ -202,60 +397,67 @@ export function ChannelList({ return (
setDragId(channel.id)} - onDragOver={e => e.preventDefault()} - onDrop={() => void onDrop(channel.id)} - onDragEnd={() => setDragId(null)} + ref={element => { + if (element) rowsRef.current.set(channel.id, element); + else rowsRef.current.delete(channel.id); + }} style={{ display: 'flex', alignItems: 'center', gap: 10, - padding: '10px 12px', + // 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。 + padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px', borderRadius: 10, - border: `0.5px solid ${isActive ? 'var(--ol-blue)' : 'var(--ol-line-strong)'}`, + 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: dragId === channel.id ? 0.5 : channel.enabled ? 1 : 0.62, - cursor: 'grab', + 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={{ - width: 7, - height: 7, - borderRadius: '50%', + color: 'var(--ol-ink-4)', + fontSize: 13, flexShrink: 0, - background: isActive ? 'var(--ol-ok)' : 'transparent', - border: isActive ? 'none' : '1px solid var(--ol-ink-4)', + cursor: draggingId === channel.id ? 'grabbing' : 'grab', + // 触摸设备上按住手柄不要变成页面滚动。 + touchAction: 'none', + padding: '2px 2px', + userSelect: 'none', }} - /> + > + ⠿ +
-
- {label} - {isActive && ( - - {t('settings.channels.inUse')} - - )} -
-
+ {label} + {/* minHeight 保证「未命名 + 没验证过」的卡片不会比别的矮一截, + 列表高度参差看起来像坏了。 */} +
{/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */} {channel.name.trim() && {presetLabel(kind, channel.providerType, t)}} {model && {model}} - {channel.lastTest?.ok && channel.lastTest.latencyMs != null && ( - {channel.lastTest.latencyMs}ms - )} - {failed && ( - - {t('settings.channels.lastFailed', { - when: relativeTime(channel.lastTest!.at, t), - })} - - )} + {/* 验证结果什么时候来的 —— 让"这条结论会过期"这件事可见。 */} + {channel.lastTest && {relativeTime(channel.lastTest.at, t)}}
+ void runTest(channel)} + t={t} + /> void onToggle(channel)} />
- - {creating && ( - setCreating(false)} - onCreated={async id => { - setCreating(false); - await refresh(); - setEditingId(id); - }} - /> - )} - - {editing && ( - { - setEditingId(null); - void refresh(); - }} + onClose={() => void closeModal()} onChanged={refresh} /> )} @@ -303,6 +495,77 @@ export function ChannelList({ ); } +/** + * 卡片上的验证按钮 —— **按钮自己就是结果容器**,不给结果另找地方摆文字。 + * + * 通过时只显示延迟数字:它既说明"通了",又带信息量(一眼看出哪张快); + * 再写一句"验证通过"是废话还占地方。失败则必须给出能指导行动的短标签 + * (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 两张渠道列表。 * @@ -334,91 +597,37 @@ export function ProvidersSection({ ); } -/** 新建:先定名字与供应商,创建拿到 id 之后才谈得上填凭据(凭据按渠道 id 作用域存)。 */ -function ChannelCreateModal({ - kind, - presets, - onClose, - onCreated, -}: { - kind: ChannelKind; - presets: PresetOption[]; - onClose: () => void; - onCreated: (id: string) => void | Promise; -}) { - const { t } = useTranslation(); - const [providerType, setProviderType] = useState(presets[0]?.id ?? ''); - const [name, setName] = useState(''); - const [busy, setBusy] = useState(false); - - const submit = async () => { - if (!providerType || busy) return; - setBusy(true); - try { - const id = await createChannel(kind, providerType, name.trim()); - await onCreated(id); - } catch (error) { - console.error('[channels] create failed', error); - emitSaved('failed', t('common.operationFailed')); - setBusy(false); - } - }; - - return ( - -
- {t('settings.channels.createTitle')} -
- - ({ - 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)} - placeholder={t('settings.channels.namePlaceholder')} - onKeyDown={e => { - if (e.key === 'Enter') void submit(); - }} - style={{ ...inputStyle, width: '100%', marginBottom: 18 }} - /> -
- - -
-
- ); -} - -function ChannelEditModal({ +/** + * 添加与编辑共用的同一个弹窗 —— 供应商、名字、凭据、测试连通都在这一屏里。 + * + * 刻意不做「先创建、再填凭据」的两步:那只是实现上需要先有渠道 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 === channel.name) return; + if (name.trim() === channel.name.trim()) return; try { await renameChannel(kind, channel.id, name.trim()); await onChanged(); @@ -428,6 +637,19 @@ function ChannelEditModal({ } }; + 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); @@ -439,17 +661,26 @@ function ChannelEditModal({ } }; - const isLocalEngine = LOCAL_ASR_PROVIDER_IDS.includes(channel.providerType); + const isLocalEngine = LOCAL_ASR_PROVIDER_IDS.includes(providerType); return ( -
- {t('settings.channels.editTitle')} -
-
- {presetLabel(kind, channel.providerType, t)} +
+ {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 }} + /> + + {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} void onChanged()} /> @@ -474,11 +707,13 @@ function ChannelEditModal({
{confirmDelete ? ( -
+
{t('settings.channels.deleteConfirm')} - +
) : ( From 7714483ef7adcb7af315720f205c414642fe0534 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Tue, 4 Aug 2026 23:48:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(channels):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=97=B6=E4=BC=98=E5=85=88=E6=8A=8A=E3=80=8C=E5=A1=AB=E8=BF=87?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E3=80=8D=E7=9A=84=E6=B8=A0=E9=81=93=E6=8E=92?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=EF=BC=8C=E5=88=AB=E8=AE=A9=E7=A9=BA=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E9=A1=B6=E5=88=B0=E6=9C=80=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `active` 指向一个已不存在的 entry 是真实会发生的:前端 prefs 里的 activeAsrProvider 与凭据库里的 active.asr 是两份数据,历史上可能不同步。原来遇到这种情况纯按字母序挑一张 排第一,完全不看那张有没有填过 key —— 结果很容易把一张空卡排到最前,用户升级后打开就 看到「未配置」,而他真正配好的那张其实还在列表下面躺着。 排序优先级改为:原 active → 填过凭据的 → 字母序(兜底,保证幂等)。 同时补一个边界测试钉死底线:即使 active 指向缺失 entry,迁移也**绝不碰任何凭据** —— 所有 key 原样留在各自 entry 里,用户把想用的那张拖回第一位就能恢复。 验证:cargo test --lib persistence::credentials(33 passed,跑前已备份 preferences.json 且跑后 md5 未变)。 Co-Authored-By: Claude Opus 5 --- .../src-tauri/src/persistence/credentials.rs | 108 +++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 3826c1ea1..a09ad9bbd 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -372,6 +372,8 @@ impl CredsLlmEntry { trait HasChannelMeta { fn meta(&self) -> &ChannelMeta; fn meta_mut(&mut self) -> &mut ChannelMeta; + /// 用户是否往这张卡里填过东西 —— 迁移排序时用来避免把空卡片排到第一。 + fn is_blank(&self) -> bool; } impl HasChannelMeta for CredsAsrEntry { @@ -381,6 +383,9 @@ impl HasChannelMeta for CredsAsrEntry { fn meta_mut(&mut self) -> &mut ChannelMeta { &mut self.channel } + fn is_blank(&self) -> bool { + self.has_no_content() + } } impl HasChannelMeta for CredsLlmEntry { @@ -390,6 +395,9 @@ impl HasChannelMeta for CredsLlmEntry { 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。 @@ -434,9 +442,26 @@ fn migrate_channel_map(map: &mut HashMap, active: } let mut keys: Vec = map.keys().cloned().collect(); - // false < true,所以 active 那把排最前;其余按字母序。 + // 排序优先级(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| { - (left != active, left.as_str()).cmp(&(right != active, right.as_str())) + 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; @@ -2837,6 +2862,85 @@ mod tests { 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(); From d6402fd59c60cf5a69d5cb35f577a8c1ee4250cd Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Wed, 5 Aug 2026 00:09:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(channels):=20=E5=86=99=E6=98=8E?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=B0=9A=E6=9C=AA=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=8C=BA=E5=88=86=E4=BB=A3=E7=A0=81=E9=87=8C=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E8=BF=9E=E6=8E=A5=E5=B1=82=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E4=B8=8E=E6=B8=A0=E9=81=93=E6=95=85=E9=9A=9C=E8=BD=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查时在代码里搜到 retry 容易误以为渠道重试做了。补两条既有行为的说明: net.rs::send_with_retry 只对连接层失败重连同一个 endpoint(拿到任何 HTTP 响应即返回、 超时不重试),永远不会换卡片;润色失败已经会回落插入 ASR 原文,所以「全渠道失败 → 出原文」这条决策天然满足,P2 要做的是在回落前多试几张卡片。 同时写明 P0 的定位:多渠道现在的价值是存档与手动切换,排第二的卡片不会被自动用上。 Co-Authored-By: Claude Opus 5 --- docs/provider-channels-plan.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/provider-channels-plan.md b/docs/provider-channels-plan.md index 47f9cfd78..24b0f1e49 100644 --- a/docs/provider-channels-plan.md +++ b/docs/provider-channels-plan.md @@ -103,6 +103,31 @@ struct ChannelRuntime { ## 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()` 的分类,按桌面场景裁剪: | 情况 | 行为 | @@ -145,6 +170,7 @@ struct ChannelRuntime { | 期 | 内容 | 可否独立发布 | | --- | --- | --- | | **P0** | 渠道数据模型 + 迁移 + 卡片 UI + 拖拽排序 + 测试连通。**不做重试** | ✅ 独立故事:「我有两把 key,想随手切」 | +| | ↑ 已完成。**此时多渠道的价值是"存档 + 手动切换",不是自动容错**:排在第二的卡片永远不会被自动用上,要用得手动拖到第一位。 | | | **P1** | 凭据显式化重构:`CredentialsVault::get(...)` → 上层解析 `ResolvedChannel` 显式下传 | ❌ 纯重构,无用户可见变化,P2 前提 | | **P2** | 重试 + 故障转移 + 429 冷却 + 超时下调 + 全挂兜底 | ✅ |