feat(providers): LLM/ASR 供应商改成渠道卡片——同一家可存多把 key,排序即优先级 (#918)

* feat(providers): LLM/ASR 供应商改成渠道卡片——同一家可存多把 key,排序即优先级

原来一个供应商只能存一份配置,换 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 <noreply@anthropic.com>

* fix(channels): 添加改成一步、拖拽在真机能用、卡片状态不再假装"健康"

三处都是装机自用后暴露的问题。

**添加渠道少一步。** 原来是「先建卡片、再填凭据」两步 —— 那只是实现上需要先有渠道 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 <noreply@anthropic.com>

* fix(channels): 迁移时优先把「填过凭据」的渠道排第一,别让空卡片顶到最前

`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 <noreply@anthropic.com>

* docs(channels): 写明重试尚未实现,并区分代码里已有的连接层重连与渠道故障转移

排查时在代码里搜到 retry 容易误以为渠道重试做了。补两条既有行为的说明:
net.rs::send_with_retry 只对连接层失败重连同一个 endpoint(拿到任何 HTTP 响应即返回、
超时不重试),永远不会换卡片;润色失败已经会回落插入 ASR 原文,所以「全渠道失败 →
出原文」这条决策天然满足,P2 要做的是在回落前多试几张卡片。

同时写明 P0 的定位:多渠道现在的价值是存档与手动切换,排第二的卡片不会被自动用上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channels): 修复渠道化 Spec 缺陷——禁用语义/order 冲突/默认值回填/LocalAsr 激活

- sync_active_channels 全部禁用时清空 active,运行时不再使用已禁用渠道凭据
- create_channel 落盘前 compact_orders,消除新卡与禁用项 order 冲突
- 换供应商时仅空槽写入 preset 默认 endpoint/model(不覆盖用户已填值)
- LocalAsr 激活前确保本地引擎渠道卡存在、启用并置顶
- 更新 provider-channels-plan 状态与待确认项

* fix(channels): 修复 Omni 路由与草稿生命周期

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-07 23:09:41 +08:00
1 parent ead09481bd
commit b4d1317e77
21 files changed
+3720 -932

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
# 供应商渠道卡片化 实施计划
> 状态:P0 已完成(2026-08-07,PR #918)
> 日期: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<String, Entry>`,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` |
| 新手引导直接嵌 `<ProvidersSection kind="asr" />` | `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, // 迁移沿用 preset id;同厂商新卡按 -2 / -3 分配独立 id
name: String, // 用户取的名字,如「硅基流动-主号」
provider_type: String, // deepseek / volcengine / sherpa-onnx-local / codex_oauth ...
// 决定协议路由 + 表单形状,必须独立于 id
enabled: bool,
order: u32, // 拖拽排序;关掉时自动置到末尾
last_error: Option<ChannelError>, // { kind, message, at } —— 卡片标红用
last_test: Option<ChannelTest>, // { ok, latency_ms, at } —— 连通测试结果
// 凭据字段沿用现有 CredsAsrEntry / CredsLlmEntry,按 provider_type 决定渲染哪些
}
// 仅内存,不落盘
struct ChannelRuntime {
cooldown_until: Option<Instant>, // 429 临时冷却
}
```
**`provider_type` 必须独立于 `id`**:否则 `coordinator.rs:341` 那条"按 provider id + 模型名路由到具体协议实现"的链会断——这是漏了就整个 ASR 挂掉的点。
`active.llm` / `active.asr` 不再是用户直接选择的第二份真相,而是由排序与开关同步计算的
**兼容缓存**;旧主链路仍读取它们,"当前使用"始终等于启用列表的第一个。
## 5. 迁移
1. 遍历现有 `providers.llm` / `providers.asr` 的每个非空 entry,各补齐渠道元信息
- `id` 沿用原 map key,`provider_type` = 原 map key,`name` = `displayName` 或 preset 显示名
- 新建同厂商的第二、第三张卡片使用 `<preset>-2`、`<preset>-3`,不改动迁移前的凭据 key
2. 原 `active.llm` / `active.asr` 指向的那张排到 **order = 0**,其余按 ASR_PRESETS / LLM_PRESETS 原顺序跟随
3. 全部默认 `enabled = true`
4. **全新安装**(无任何 entry):按平台预置
- Windows → 一张 Foundry 本地 ASR 卡片(保住开箱即用)
- mac / Linux → 不预置,走引导
5. 迁移必须幂等,且失败时保留原 JSON 不动(参考现有 `load_credentials_for_update` 的写法)
## 6. 重试策略
> **状态:未实现,属于 P2。** P0 里一次失败就是一次失败,不会换渠道。
> 下表是已定但**尚未落地**的目标行为。
### 6.0 代码里已经存在的两样东西(别和渠道故障转移混为一谈)
排查时容易在代码里搜到 `retry` 就以为做了,这两处都是**既有代码**,与渠道无关:
1. **`net.rs::send_with_retry` —— 连接层重连,不是渠道切换。**
只对 `err.is_connect()`(TCP 握手被拒 / 连接重置,请求**尚未送达**服务端)重试,
150/300/600/900ms 退避。**拿到任何 HTTP 响应就直接返回**(含 429/401/5xx),
超时明确不重试。它重连的始终是同一个 endpoint,永远不会换到另一张卡片。
2. **润色失败已经会回落 ASR 原文。**
`coordinator/polish_flow.rs::polish_or_passthrough` 的失败分支:
```rust
Err(e) => {
log::error!("[coord] polish failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
```
也就是说,「全部渠道试完仍失败 → 插入 ASR 原文」这条决策**天然满足**,
P2 要做的只是在回落之前多试几张卡片,而不是新建一条兜底路径。
### 6.1 目标行为(P2)
照搬 New API `shouldRetry()` 的分类,按桌面场景裁剪:
| 情况 | 行为 |
| --- | --- |
| 429 | **切下一个** + 当前渠道 60s 冷却 |
| 401 / 403 | **切下一个** + 卡片标红(不自动禁用) |
| 5xx / 连接失败 | **切下一个** + 卡片标红 |
| 超时 | **不切**,直接失败(本期决策) |
| 400 参数错误 | **不切**(换渠道多半是同样的错) |
| 2xx | 成功 |
| 全部启用渠道试完仍失败 | 插入 ASR 原文 + 提示 |
**不抄** New API 的:`Weight` 加权负载均衡(单用户无负载可均衡,随机选渠道反而让"在用哪个"不可预测)、`Group` / `UsedQuota` / `Balance`(多租户计费概念)、`AutoBan`(桌面软件静默关用户配置会让人一脸懵)。
**缓一缓**:`ModelMapping` / `ParamOverride`,有用但非第一版必需。
## 7. UI
```
┌─ LLM 润色 ──────────────────────────────┐
│ ⠿ ● 硅基流动-主号 deepseek-v4 28ms ⋮ │ ← 生效中
│ ⠿ ○ Ark-备用 deepseek-v3-2 — ⋮ │ ← 备用
│ ⠿ ○ 阶跃星辰 (限流中 · 47s) ⋮ │ ← 429 冷却
│ ⠿ ⊘ OpenAI (上次失败 · 401) ⋮ │ ← 已关闭,沉底
│ + 添加渠道 │
└──────────────────────────────────────────┘
```
添加/编辑弹窗:名字 → 选供应商(自动填 baseUrl / 模型占位)→ 按 `provider_type` 渲染凭据字段
→ 「测试连通」;字段自动保存,关闭只负责退出弹窗。
可复用的现成件:
- `validateProviderCredentials` / `listProviderModels`(`ProvidersSection.tsx:854` 起)
- 按 provider 分支渲染凭据字段的逻辑(火山双鉴权模式、讯飞双字段、百炼词表等)
- 本地引擎卡片不显示凭据字段;模型下载与切换继续集中在「高级 → 本地模型」,避免两处管理同一份模型状态
**新手引导**:列表为空时直接摊开添加表单,跳过空态与加号,省一次点击。
**平台过滤**:macOS 只显示 Qwen3 Local / Apple Speech;Windows 只显示 Foundry /
Sherpa;Linux 与 Android 不显示这些桌面专有本地引擎。云端供应商全平台可选。
**草稿回收**:保持自动保存。只有打开后从未发生任何用户交互的草稿会在关闭时回收;
改过名字、供应商、凭据、模型,或执行过验证/模型拉取后都必须保留,即使内容后来清空
或异步保存失败。这样无凭据的本地引擎 / Apple Speech / Codex OAuth 也能正常创建,且
关闭弹窗不会与 blur/debounce 保存竞争删除卡片。
## 8. 分期
| 期 | 内容 | 可否独立发布 |
| --- | --- | --- |
| **P0** | 渠道数据模型 + 迁移 + 卡片 UI + 拖拽排序 + 测试连通。**不做重试** | ✅ 独立故事:「我有两把 key,想随手切」 |
| | ↑ 已完成(PR #918)。**此时多渠道的价值是"存档 + 手动切换",不是自动容错**:排在第二的卡片永远不会被自动用上,要用得手动拖到第一位。 | |
| **P1** | 凭据显式化重构:`CredentialsVault::get(...)` → 上层解析 `ResolvedChannel` 显式下传 | ❌ 纯重构,无用户可见变化,P2 前提 |
| **P2** | 重试 + 故障转移 + 429 冷却 + 超时下调 + 全挂兜底 | ✅ |
P1 是本需求最大的单块工作量,比卡片 UI 大得多。P1 + P2 合成第二个 PR。
## 9. 待确认
- [x] 拖拽排序:手写 pointer 事件(已定)。Tauri webview 默认 `dragDropEnabled` 会吞掉
HTML5 的 `dragstart`/`drop`,`draggable` 在打包后的 app 里不触发;pointer 方案
Windows / Android 行为一致,并配合「拖拽结束吞掉补发 click」避免关掉设置弹窗。
- [x] 卡片列表的移动端(Android)形态:与桌面共用同一渠道 UI;Android 走
`load_credentials` 的同一条迁移路径,无需单独实现。
- [x] Android 加密信封:未改版本号。v2 载荷新增的 `providerType`/`order`/`enabled`/
`lastTest` 均为 `Option` 或带默认值字段,老版本 serde 忽略未知字段,可降级读取。
- [x] P0 验收判据:已满足(同供应商多卡、重启后顺序与内容不丢、拖拽后第一张生效),
由 `persistence::credentials` 迁移/排序测试与作者 macOS 实机验证覆盖。