* 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>
12 KiB
供应商渠道卡片化 实施计划
状态:P0 已完成(2026-08-07,PR #918) 日期:2026-08-04 范围:设置 → AI 提供商,LLM 润色 + ASR 语音转写 参考:Calcium-Ion/new-api 的 Channel 模型与重试策略
1. 要解决的问题
今天一个供应商只能存一份配置(一把 key、一个 endpoint、一个模型)。实际使用中:
- 同一家有多把 key(主号 / 备号 / 白嫖号),现在只能存一把,换 key 靠手动覆盖粘贴
- key 之间要频繁切换,切换过程中旧配置就丢了
- 某把 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. 数据模型
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. 迁移
- 遍历现有
providers.llm/providers.asr的每个非空 entry,各补齐渠道元信息id沿用原 map key,provider_type= 原 map key,name=displayName或 preset 显示名- 新建同厂商的第二、第三张卡片使用
<preset>-2、<preset>-3,不改动迁移前的凭据 key
- 原
active.llm/active.asr指向的那张排到 order = 0,其余按 ASR_PRESETS / LLM_PRESETS 原顺序跟随 - 全部默认
enabled = true - 全新安装(无任何 entry):按平台预置
- Windows → 一张 Foundry 本地 ASR 卡片(保住开箱即用)
- mac / Linux → 不预置,走引导
- 迁移必须幂等,且失败时保留原 JSON 不动(参考现有
load_credentials_for_update的写法)
6. 重试策略
状态:未实现,属于 P2。 P0 里一次失败就是一次失败,不会换渠道。 下表是已定但尚未落地的目标行为。
6.0 代码里已经存在的两样东西(别和渠道故障转移混为一谈)
排查时容易在代码里搜到 retry 就以为做了,这两处都是既有代码,与渠道无关:
-
net.rs::send_with_retry—— 连接层重连,不是渠道切换。 只对err.is_connect()(TCP 握手被拒 / 连接重置,请求尚未送达服务端)重试, 150/300/600/900ms 退避。拿到任何 HTTP 响应就直接返回(含 429/401/5xx), 超时明确不重试。它重连的始终是同一个 endpoint,永远不会换到另一张卡片。 -
润色失败已经会回落 ASR 原文。
coordinator/polish_flow.rs::polish_or_passthrough的失败分支: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. 待确认
- 拖拽排序:手写 pointer 事件(已定)。Tauri webview 默认
dragDropEnabled会吞掉 HTML5 的dragstart/drop,draggable在打包后的 app 里不触发;pointer 方案 Windows / Android 行为一致,并配合「拖拽结束吞掉补发 click」避免关掉设置弹窗。 - 卡片列表的移动端(Android)形态:与桌面共用同一渠道 UI;Android 走
load_credentials的同一条迁移路径,无需单独实现。 - Android 加密信封:未改版本号。v2 载荷新增的
providerType/order/enabled/lastTest均为Option或带默认值字段,老版本 serde 忽略未知字段,可降级读取。 - P0 验收判据:已满足(同供应商多卡、重启后顺序与内容不丢、拖拽后第一张生效),
由
persistence::credentials迁移/排序测试与作者 macOS 实机验证覆盖。