Skip to content

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

Open
bigsongeth wants to merge 4 commits into
Open-Less:betafrom
bigsongeth:feat/provider-channels
Open

feat(providers): LLM/ASR 供应商改成渠道卡片——同一家可存多把 key,排序即优先级#918
bigsongeth wants to merge 4 commits into
Open-Less:betafrom
bigsongeth:feat/provider-channels

Conversation

@bigsongeth

@bigsongeth bigsongeth commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

User description

这个 PR 解决什么

一个供应商只能存一份配置,换 key 只能把旧的覆盖掉;想在主号/备号之间来回切,每次都要重新粘贴。

把「服务 → AI 提供商」从「下拉选厂商 + 一组字段」改成一张张可命名、可排序、可开关的渠道卡片。同一家厂商可以有多张卡片、各自一把 key,切换只是把卡片拖到最上面,旧的那把原样留着。

心智只有一条:排序即优先级,列表里第一个启用的就是当前生效的渠道。关掉的渠道自动沉到末尾;后端不另存「当前选中」,避免「列表第一张是 A、实际请求打的是 B」这种两处真相。

设计与分期见 docs/provider-channels-plan.md

范围(重要)

本 PR 只做多渠道的存储、编辑与切换不含失败重试与故障转移

现在的行为仍是:一次失败就是一次失败(润色失败照旧回落插入 ASR 原文),排在第二的卡片不会被自动用上。也就是说,多渠道当前的价值是「存档 + 手动切换」,不是「自动容错」。

重试要求「这次请求换用渠道 B」,而听写/润色主链路目前是隐式读全局 active 的(CredentialsVault::get(...) 自己去查 active.asr/llm,调用方无法指定渠道)。那几十处的显式化是独立的一步,留给后续 PR,本 PR 主链路一行没动。

存储与迁移

  • ChannelMetaproviderType / order / enabled / lastTest)以 serde(flatten) 嵌进 ASR、LLM 两个 entry;v1 老 payload 照常反序列化。
  • providerType 与渠道 id 解耦:同一家厂商的多张卡片各有自己的 id,但 providerType 都指向同一个厂商实现。coordinator::resolve_effective_asr_provider 与 commands 里几十处 == PROVIDER_ID 的比较依赖它,拿成 id 会让整个 ASR 路由失效。
  • 迁移幂等:迁移出的渠道 id 沿用原 preset id,不生成 uuid —— 老用户的 map key 一个字节都不变,重复执行结果一致。
  • 迁移只在内存里做、不主动落盘:启动时写 keyring 会在 macOS 触发钥匙串 ACL 弹窗,留给下一次真实写入顺带固化。
  • 排序优先级:原 active → 填过凭据的 → 字母序。active 指向一个已不存在的 entry 是真实会发生的(前端 prefs 与凭据库里的 active 是两份数据),此时若纯按字母序挑,很容易把一张空卡排到第一,用户升级后就看到「未配置」,而配好的那张还在列表下面躺着。
  • Windows 全新安装预置一张 Foundry 本地 ASR 卡片,保住开箱即用。

顺手修掉的既有缺陷

这些都不是本次改出来的,是渠道化把它们暴露了:

缺陷 后果
ChannelMetaderive(Default)enabled 默认为 false,而 write_accountentry().or_default() 建 entry 新写入的渠道一出生就是禁用的,表现为「填了 key 却不生效」
clean_credentialsretain(!is_empty) 删掉空 entry 刚点「添加渠道」、还没填 key 的卡片被静默删掉
set_credential / read_credentialprovider 作用域硬性拒绝 LLM 账户 编辑列表里第 3 张 LLM 卡片时无处可写(已补 get/set_for_llm_provider
Tauri webview 默认 dragDropEnabled,吞掉 HTML5 的 dragstart/drop draggable 在打包后的 app 里完全不触发(浏览器预览里却是好的)。已改用 pointer 事件手写,Windows / Android 行为也一致
setPointerCapture 会把后续事件重定向到手柄,浏览器补发的 click 落到设置弹窗遮罩上(遮罩挂着 onClick={onClose} 拖一下卡片,整个设置面板被关掉

UI

  • 卡片列表:拖拽排序、开关沉底、当前那张用左侧竖条标示。
  • 刻意不用绿点或「生效中」文字:它们会被读成「这张是健康的」,可它只代表排在最前面 —— 一张 key 已失效的卡片照样排第一。优先级与健康度是两个正交维度,不该压成一个视觉。
  • 验证按钮在卡片上(开关左侧),按钮自己就是结果容器:没验过显示「验证」、通过显示延迟数字(284ms,既说明通了又能比快慢)、失败显示能指导行动的短标签(✗ 401 改 key / ✗ 429 等会儿 / ✗ 超时 查网络)。副行显示上次验证多久以前,超过一天褪色 —— 让「这条结论会过期」可见。
  • 不做自动验证:验证是真实 API 调用(LLM 走一次真润色、ASR 传一段静音音频),打开设置就全部验一遍等于按卡片数烧额度,还容易撞进限流。
  • 添加渠道一步完成:供应商、名字、密钥、地址、模型、连接检查都在同一个弹窗里。草稿卡片在后台先建(凭据必须按渠道 id 落盘),什么都没填就关掉时自动回收,不留空卡片。
  • 本地引擎与 Codex OAuth 不做预置固定卡片,与云端厂商一样从「添加渠道」里选,只是编辑时没有 key/地址字段。
  • 新手引导列表为空时直接摊开添加表单。
  • 五种语言文案齐备。

验证

cargo test --lib persistence::credentials   # 33 passed
cargo test --lib commands::                 # 104 passed
tsc --noEmit                                # 0
cargo check --lib                           # 0 error

新增测试覆盖:id 沿用 preset id、迁移幂等(逐字节比对)、迁移后 lookup_account 行为不变、迁移绝不碰凭据(即使 active 指向缺失 entry)、优先选已配置渠道、排序/开关边界(关掉沉底、重开落到启用组末尾、order 压实、前端漏报 id 不撞车)、新建空卡片不被 clean_credentials 删掉、v1 payload 兼容、同厂商多卡片的 providerType 独立。

已在 macOS 上装机自用:老配置迁移后 6 张 ASR 卡片一张没丢,实际在用的那张正确排在第一位,模型名按渠道正确隔离。

已 rebase 到含 #915(ZenMux)的最新 beta,ZenMux 的两处分支(vocabulary note、AsrAdvancedOptions)已并入新的渠道字段组件。

🤖 Generated with Claude Code


PR Type

Enhancement, Tests


Description

  • Provider configs become reorderable, toggleable channel cards.

  • Add idempotent in-memory v1→v2 credentials migration.

  • Active provider derived from first enabled channel.

  • Add channel management IPC, UI, and translations.


Diagram Walkthrough

flowchart LR
  A["Provider settings"] --> B["Channel cards: ordered & enabled"]
  B --> C["First enabled = active channel"]
  C --> D["providerType routes protocol"]
  D --> E["ASR / LLM request"]
Loading

File Walkthrough

Relevant files
Enhancement
10 files
credentials.rs
Add channel metadata, migration, and management                   
+1260/-21
providers.rs
Scope validation and model listing per channel                     
+180/-83
channels.rs
Add Tauri IPC commands for channel management                       
+157/-0 
credentials.rs
Route credential reads and writes by channel kind               
+36/-22 
channels.ts
Add channel IPC wrapper with browser mock                               
+176/-0 
Onboarding.tsx
Use channel list during onboarding with auto-create           
+3/-3     
ChannelList.tsx
Add channel card list UI component                                             
+793/-0 
asr-credentials.ts
Adapt ASR credentials helpers to channel ids                         
+5/-2     
tabs.tsx
Route provider settings to channel list page                         
+1/-1     
ProvidersSection.tsx
Refactor providers section to embed channel list                 
+211/-407
Configuration changes
1 files
lib.rs
Register new channel commands in invoke handler                   
+18/-0   
Miscellaneous
2 files
mod.rs
Export channels module from commands                                         
+4/-2     
index.ts
Export channel IPC functions from index                                   
+14/-0   
I18n
5 files
ja.ts
Add Japanese channel management translations                         
+28/-0   
zh-TW.ts
Add Traditional Chinese channel translations                         
+28/-0   
ko.ts
Add Korean channel management translations                             
+28/-0   
zh-CN.ts
Add Simplified Chinese channel translations                           
+28/-0   
en.ts
Add English channel management translations                           
+28/-0   
Documentation
1 files
provider-channels-plan.md
Document provider channels design and phasing                       
+184/-0 

bigsongeth and others added 4 commits August 5, 2026 00:15
原来一个供应商只能存一份配置,换 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>
三处都是装机自用后暴露的问题。

**添加渠道少一步。** 原来是「先建卡片、再填凭据」两步 —— 那只是实现上需要先有渠道 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>
`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>
排查时在代码里搜到 retry 容易误以为渠道重试做了。补两条既有行为的说明:
net.rs::send_with_retry 只对连接层失败重连同一个 endpoint(拿到任何 HTTP 响应即返回、
超时不重试),永远不会换卡片;润色失败已经会回落插入 ASR 原文,所以「全渠道失败 →
出原文」这条决策天然满足,P2 要做的是在回落前多试几张卡片。

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

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

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

915 - Partially compliant

Compliant requirements:

(none)

Non-compliant requirements:

  • 未新增 ZenMuxJson 请求格式,diff 中没有任何 ZenMux / audio/transcriptions 相关改动。
  • 未新增 language 的 ISO 639-1 映射逻辑。
  • 未新增 enable_itn 高级配置或「数字归一化」开关。
  • 未新增 zenmux ASR 预设与默认 endpoint/model。
  • 未新增针对 ZenMux 的 30s 分片行为。
  • 未新增 ZenMux 相关 i18n 键与测试。

Requires further human verification:

(none)

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Issue

When every channel of a kind is disabled, sync_active_channels leaves
active.asr / active.llm pointing at the previously active (now disabled)
channel. lookup_account resolves credentials from the entry at active.*
without checking the enabled flag, so the app keeps using a channel the
user explicitly turned off. Repro: with a single ASR channel, toggle it
off, then perform dictation; transcription still goes to that provider.
The active id should be cleared (or lookups should treat disabled
entries as unconfigured) so disabling the last channel actually disables
the feature.

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;
    }
}
Ordering Bug

next_order only considers enabled channels, so when disabled channels
exist (compact_orders keeps them at orders k+1..), a newly created
channel is assigned the same order as the first disabled channel.
create_channel does not re-compact and channel_summaries sorts purely by
(order, id), so a disabled card can appear above the newly created
enabled card until the user reorders. Example: enabled 'a' order 0,
disabled 'b' order 1, create 'c' -> 'c' gets order 1 and, if 'b' < 'c'
alphabetically, the disabled 'b' is listed before the enabled 'c',
violating the 'disabled channels sink to the bottom' invariant.

fn next_order<V: HasChannelMeta>(map: &HashMap<String, V>) -> u32 {
    map.values()
        .filter(|entry| entry.meta().enabled)
        .filter_map(|entry| entry.meta().order)
        .max()
        .map(|max| max.saturating_add(1))
        .unwrap_or(0)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant