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 实机验证覆盖。
@@ -0,0 +1,157 @@
//! 渠道卡片管理的 IPC 面。
//!
//! 一张卡片 = 一份可命名、可排序、可开关的供应商配置。同一家厂商可以有多张卡片
//! (多把 key),此时渠道 id 与 `providerType` 分离 —— 前者是 map key,后者决定
//! 协议路由。详见 `persistence::credentials` 里 `ChannelMeta` 的说明。
//!
//! 凭据本身不走这里:前端按渠道 id 调 `read_credential` / `set_credential`
//! (`provider` 参数传渠道 id),避免密钥随列表批量出栈。
use super::*;
use crate::persistence::{ChannelKind, ChannelSummary};
fn parse_kind(kind: &str) -> Result<ChannelKind, String> {
ChannelKind::parse(kind).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_channels(window: Window, kind: String) -> Result<Vec<ChannelSummary>, 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<String, String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::create_channel(kind, &provider_type, &name).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel create worker failed: {e}"))?
}
#[tauri::command]
pub async fn set_channel_provider_type(
window: Window,
kind: String,
id: String,
provider_type: String,
) -> Result<(), String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::set_channel_provider_type(kind, &id, &provider_type)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel provider type worker failed: {e}"))?
}
/// 关闭「添加渠道」弹窗时回收没填任何内容的草稿卡片;返回是否真的删了。
#[tauri::command]
pub async fn delete_channel_if_blank(
window: Window,
kind: String,
id: String,
) -> Result<bool, String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::delete_channel_if_blank(kind, &id).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel cleanup worker failed: {e}"))?
}
#[tauri::command]
pub async fn rename_channel(
window: Window,
kind: String,
id: String,
name: String,
) -> Result<(), String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::rename_channel(kind, &id, &name).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel rename worker failed: {e}"))?
}
#[tauri::command]
pub async fn delete_channel(window: Window, kind: String, id: String) -> Result<(), String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::delete_channel(kind, &id).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel delete worker failed: {e}"))?
}
#[tauri::command]
pub async fn set_channel_enabled(
window: Window,
kind: String,
id: String,
enabled: bool,
) -> Result<(), String> {
ensure_main_window(&window)?;
let kind = parse_kind(&kind)?;
tauri::async_runtime::spawn_blocking(move || {
CredentialsVault::set_channel_enabled(kind, &id, enabled).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("channel toggle worker failed: {e}"))?
}
#[tauri::command]
pub async fn reorder_channels(
window: Window,
kind: String,
ids: Vec<String>,
) -> 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<u32>,
error: Option<String>,
) -> 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}"))?
}
@@ -234,25 +234,14 @@ pub async fn set_credential(
}
let acc = parsed.expect("non-extra credential account must be parsed");
if let Some(provider) = provider {
if !matches!(
acc,
CredentialAccount::VolcengineAppKey
| CredentialAccount::VolcengineAccessKey
| CredentialAccount::VolcengineResourceId
| CredentialAccount::VolcengineAuthMode
| CredentialAccount::VolcengineApiKey
| CredentialAccount::AsrApiKey
| CredentialAccount::AsrEndpoint
| CredentialAccount::AsrModel
| CredentialAccount::AsrVocabularyId
| CredentialAccount::AsrAdvancedConfig
| CredentialAccount::XfyunAppId
| CredentialAccount::XfyunApiKey
) {
return Err("provider-scoped credential must be an ASR account".to_string());
// 渠道化后 `provider` 是**渠道 id**,LLM 侧同样需要按 id 定位 —— 用户编辑
// 的可能是列表里第 3 张卡片,而不是当前生效的那张。
match account_channel_kind(acc) {
ChannelKind::Asr => CredentialsVault::set_for_asr_provider(&provider, acc, &value)
.map_err(|e| e.to_string()),
ChannelKind::Llm => CredentialsVault::set_for_llm_provider(&provider, acc, &value)
.map_err(|e| e.to_string()),
}
CredentialsVault::set_for_asr_provider(&provider, acc, &value)
.map_err(|e| e.to_string())
} else if value.is_empty() {
CredentialsVault::remove(acc).map_err(|e| e.to_string())
} else {
@@ -373,7 +362,12 @@ pub async fn read_credential(
}
let acc = parsed.expect("non-extra credential account must be parsed");
if let Some(provider) = provider {
CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string())
match account_channel_kind(acc) {
ChannelKind::Asr => CredentialsVault::get_for_asr_provider(&provider, acc)
.map_err(|e| e.to_string()),
ChannelKind::Llm => CredentialsVault::get_for_llm_provider(&provider, acc)
.map_err(|e| e.to_string()),
}
} else {
CredentialsVault::get(acc).map_err(|e| e.to_string())
}
@@ -382,7 +376,33 @@ pub async fn read_credential(
.map_err(|e| format!("credential read worker failed: {e}"))?
}
fn ensure_main_window(window: &Window) -> Result<(), String> {
/// 一个凭据账户属于 ASR 面还是 LLM 面 —— 决定按渠道 id 定位时查哪张 map。
fn account_channel_kind(account: CredentialAccount) -> ChannelKind {
match account {
CredentialAccount::ArkApiKey
| CredentialAccount::ArkModelId
| CredentialAccount::ArkEndpoint => ChannelKind::Llm,
CredentialAccount::VolcengineAppKey
| CredentialAccount::VolcengineAccessKey
| CredentialAccount::VolcengineResourceId
| CredentialAccount::VolcengineAuthMode
| CredentialAccount::VolcengineApiKey
| CredentialAccount::AsrApiKey
| CredentialAccount::AsrEndpoint
| CredentialAccount::AsrModel
| CredentialAccount::AsrVocabularyId
| CredentialAccount::AsrAdvancedConfig
| CredentialAccount::XfyunAppId
| CredentialAccount::XfyunApiKey => ChannelKind::Asr,
// Omni 凭据走独立命名空间、从不按渠道 id 定位(前端写入不带 provider);
// 映射到 Asr 只为穷尽 match,实际调用点不可达。
CredentialAccount::OmniApiKey
| CredentialAccount::OmniEndpoint
| CredentialAccount::OmniModel => ChannelKind::Asr,
}
}
pub(crate) fn ensure_main_window(window: &Window) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {
@@ -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,
@@ -66,6 +66,7 @@ pub(crate) use crate::types::{
StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore,
};
mod channels;
mod credentials;
mod dictation;
mod dictionary;
@@ -92,6 +93,7 @@ mod selection_polish;
mod selection_polish_preview;
mod style_packs;
pub use channels::*;
pub use credentials::*;
pub use dictation::*;
pub use dictionary::*;
@@ -2,6 +2,92 @@ use super::*;
use base64::Engine;
use std::collections::HashMap;
/// 一次连通测试 / 模型列表请求所针对的渠道。
///
/// 渠道化之前这两条路径都隐式读"当前生效"的凭据;卡片化之后用户会对列表里**任意**
/// 一张卡片点「测试连通」,包括还没轮到它生效的那些。`channel = None` 保留旧语义
/// (当前生效的渠道),供未指定渠道的老调用点使用。
///
/// 注意这只覆盖测试与模型列表两条路径 —— 真正的听写 / 润色链路仍走隐式 active,
/// 那部分的显式化是 P1 的工作(见 docs/provider-channels-plan.md)。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProviderKind {
Asr,
Llm,
Omni,
}
impl ProviderKind {
fn parse(value: &str) -> Result<Self, String> {
match value {
"asr" => Ok(Self::Asr),
"llm" => Ok(Self::Llm),
"omni" => Ok(Self::Omni),
other => Err(format!("unknown provider kind: {other}")),
}
}
}
pub(crate) struct ProviderScope {
kind: ProviderKind,
channel: Option<String>,
}
impl ProviderScope {
fn new(kind: &str, channel: Option<String>) -> Result<Self, String> {
let kind = ProviderKind::parse(kind)?;
if kind == ProviderKind::Omni && channel.is_some() {
return Err("omni provider does not support channel id".to_string());
}
Ok(Self { kind, channel })
}
/// 读该渠道的凭据;未指定渠道时回落到当前生效的那张。
fn get(&self, account: CredentialAccount) -> Result<Option<String>, String> {
match (&self.channel, self.kind) {
(Some(id), ProviderKind::Asr) => CredentialsVault::get_for_asr_provider(id, account),
(Some(id), ProviderKind::Llm) => CredentialsVault::get_for_llm_provider(id, account),
(Some(_), ProviderKind::Omni) => {
return Err("omni provider does not support channel id".to_string())
}
(None, _) => CredentialsVault::get(account),
}
.map_err(|e| e.to_string())
}
/// 该渠道的厂商 id —— 决定走哪套协议。
fn provider_type(&self) -> String {
match (&self.channel, self.kind) {
(Some(id), ProviderKind::Asr) => {
CredentialsVault::get_channel_provider_type(ChannelKind::Asr, id)
.unwrap_or_else(|| id.clone())
}
(Some(id), ProviderKind::Llm) => {
CredentialsVault::get_channel_provider_type(ChannelKind::Llm, id)
.unwrap_or_else(|| id.clone())
}
(Some(_), ProviderKind::Omni) => CredentialsVault::get_active_omni(),
(None, ProviderKind::Asr) => CredentialsVault::get_active_asr(),
(None, ProviderKind::Llm) => CredentialsVault::get_active_llm(),
(None, ProviderKind::Omni) => CredentialsVault::get_active_omni(),
}
}
fn llm_extra_headers(&self) -> HashMap<String, String> {
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<f32> {
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,31 +99,43 @@ pub struct ProviderModelsResult {
models: Vec<String>,
}
/// `channel_id = None` 时测当前生效的渠道(老行为);卡片上的「测试连通」会带上
/// 那张卡片的 id,这样还没轮到生效的渠道也能验证。
#[tauri::command]
pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheckResult, String> {
match kind.as_str() {
"llm" => validate_llm_provider()
pub async fn validate_provider_credentials(
kind: String,
channel_id: Option<String>,
) -> Result<ProviderCheckResult, String> {
let scope = ProviderScope::new(&kind, channel_id)?;
let scope = &scope;
match scope.kind {
ProviderKind::Llm => validate_llm_provider(scope)
.await
.map(|()| ProviderCheckResult { ok: true }),
"asr" => validate_asr_provider()
ProviderKind::Asr => validate_asr_provider(scope)
.await
.map(|()| ProviderCheckResult { ok: true }),
"omni" => validate_omni_provider()
ProviderKind::Omni => validate_omni_provider()
.await
.map(|()| ProviderCheckResult { ok: true }),
_ => Err(format!("unknown provider kind: {kind}")),
}
}
#[tauri::command]
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID {
pub async fn list_provider_models(
kind: String,
channel_id: Option<String>,
) -> Result<ProviderModelsResult, String> {
let scope = ProviderScope::new(&kind, channel_id)?;
let scope = &scope;
if scope.kind == ProviderKind::Asr && scope.provider_type() == crate::asr::bailian::PROVIDER_ID
{
// 统一「阿里云百炼」入口:三条协议(实时 fun-asr-realtime / 实时 qwen3 /
// 录音文件 fun-asr-flash)收成一个 provider。百炼各网关都没有模型列表 HTTP
// 接口,列表是静态的;但先跑一次与「验证」相同的、按当前所选模型对应协议的
// 连通性检查(validate_asr_provider 已按模型路由),避免 Key/endpoint 全错时
// 也显示成功。随后返回三个可选模型供下拉。
validate_asr_provider().await?;
validate_asr_provider(scope).await?;
// 静态清单只是常用快捷项;协议按模型名自动路由,用户也可在模型框直接手填
// 已支持的 DashScope ASR 模型;不支持的模型会在验证/开始录音前明确拒绝。
return Ok(ProviderModelsResult {
@@ -59,11 +157,12 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::qwen_realtime::PROVIDER_ID
if scope.kind == ProviderKind::Asr
&& scope.provider_type() == crate::asr::qwen_realtime::PROVIDER_ID
{
// 与 bailian 同理:Realtime 网关无模型列表接口,先做真实连通性检查,
// 列表为官方文档在案的稳定别名 + 快照版本。
validate_qwen3_realtime_asr_provider().await?;
validate_qwen3_realtime_asr_provider(scope).await?;
return Ok(ProviderModelsResult {
models: vec![
crate::asr::qwen_realtime::DEFAULT_MODEL.to_string(),
@@ -72,13 +171,13 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::mimo::PROVIDER_ID {
if scope.kind == ProviderKind::Asr && scope.provider_type() == crate::asr::mimo::PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![crate::asr::mimo::DEFAULT_MODEL.to_string()],
});
}
if kind == "asr"
&& CredentialsVault::get_active_asr() == crate::asr::dashscope_multimodal::PROVIDER_ID
if scope.kind == ProviderKind::Asr
&& scope.provider_type() == crate::asr::dashscope_multimodal::PROVIDER_ID
{
// multimodal-generation 无模型列表 HTTP 接口;与 mimo 同,返回静态别名。
return Ok(ProviderModelsResult {
@@ -88,13 +187,15 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::elevenlabs::PROVIDER_ID {
validate_elevenlabs_asr_provider().await?;
if scope.kind == ProviderKind::Asr
&& scope.provider_type() == crate::asr::elevenlabs::PROVIDER_ID
{
validate_elevenlabs_asr_provider(scope).await?;
return Ok(ProviderModelsResult {
models: vec![crate::asr::elevenlabs::DEFAULT_MODEL.to_string()],
});
}
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
if scope.kind == ProviderKind::Llm && scope.provider_type() == CODEX_OAUTH_PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![
CODEX_DEFAULT_MODEL.to_string(),
@@ -104,7 +205,7 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
],
});
}
let config = read_openai_provider_config(&kind)?;
let config = read_openai_provider_config(scope)?;
fetch_provider_models(&config)
.await
.map(|models| ProviderModelsResult { models })
@@ -117,45 +218,42 @@ pub(crate) struct ProviderConfig {
pub(crate) temperature: Option<f32>,
}
fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
fn read_openai_provider_config(scope: &ProviderScope) -> Result<ProviderConfig, String> {
// `openai-compatible` 允许 API Key 留空(LAN 无鉴权端点);其余 ASR 提供商
// 仍必填,与运行时门禁 ensure_asr_credentials 保持一致。
let (api_key_account, endpoint_account, api_key_required) = match kind {
"llm" => (
let (api_key_account, endpoint_account, api_key_required) = match scope.kind {
ProviderKind::Llm => (
CredentialAccount::ArkApiKey,
CredentialAccount::ArkEndpoint,
false,
),
"asr" => (
ProviderKind::Asr => (
CredentialAccount::AsrApiKey,
CredentialAccount::AsrEndpoint,
CredentialsVault::get_active_asr()
!= crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID,
scope.provider_type() != crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID,
),
// 多模态(Omni)模型:独立命名空间,OpenAI 兼容通道要求 API Key + Base URL。
"omni" => (
ProviderKind::Omni => (
CredentialAccount::OmniApiKey,
CredentialAccount::OmniEndpoint,
true,
),
_ => 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 (extra_headers, temperature) = if scope.kind == ProviderKind::Llm {
let active_llm = scope.provider_type();
(
CredentialsVault::get_active_llm_extra_headers(),
openai_compatible_temperature_for_provider(
&active_llm,
CredentialsVault::get_active_llm_temperature(),
),
scope.llm_extra_headers(),
openai_compatible_temperature_for_provider(&active_llm, scope.llm_temperature()),
)
} else if kind == "omni" {
} else if scope.kind == ProviderKind::Omni {
let active_omni = CredentialsVault::get_active_omni();
(
CredentialsVault::get_active_omni_extra_headers(),
@@ -188,13 +286,14 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
})
}
async fn validate_llm_provider() -> Result<(), String> {
async fn validate_llm_provider(scope: &ProviderScope) -> Result<(), String> {
let llm_thinking_enabled = PreferencesStore::new()
.map_err(|e| e.to_string())?
.get()
.llm_thinking_enabled;
if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
if scope.provider_type() == CODEX_OAUTH_PROVIDER_ID {
let model = scope
.get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string());
@@ -218,9 +317,10 @@ async fn validate_llm_provider() -> Result<(), String> {
.map_err(provider_llm_error_message);
}
let config = read_openai_provider_config("llm")?;
let active_llm = CredentialsVault::get_active_llm();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
let config = read_openai_provider_config(scope)?;
let active_llm = scope.provider_type();
let model = scope
.get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
.filter(|s| !s.is_empty())
.ok_or_else(|| "llmModelMissing".to_string())?;
@@ -276,8 +376,8 @@ async fn validate_omni_provider() -> Result<(), String> {
.map_err(provider_llm_error_message)
}
async fn validate_asr_provider() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let active_asr = scope.provider_type();
if active_asr_is_keyless_for_validation(&active_asr) {
return Ok(());
}
@@ -285,53 +385,57 @@ async fn validate_asr_provider() -> Result<(), String> {
if active_asr == crate::asr::bailian::PROVIDER_ID {
// 统一百炼:按所选模型验证对应协议(endpoint 由前端按模型同步,各 validator
// 读到的都是该协议的正确地址)。
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.ok()
.flatten()
.unwrap_or_default();
let effective = crate::coordinator::resolve_effective_asr_provider(&active_asr, &model)?;
if effective == crate::asr::qwen_realtime::PROVIDER_ID {
return validate_qwen3_realtime_asr_provider().await;
return validate_qwen3_realtime_asr_provider(scope).await;
}
if effective == crate::asr::dashscope_multimodal::PROVIDER_ID {
return validate_dashscope_multimodal_asr_provider().await;
return validate_dashscope_multimodal_asr_provider(scope).await;
}
return validate_bailian_asr_provider().await;
return validate_bailian_asr_provider(scope).await;
}
if active_asr == crate::asr::qwen_realtime::PROVIDER_ID {
return validate_qwen3_realtime_asr_provider().await;
return validate_qwen3_realtime_asr_provider(scope).await;
}
if active_asr == crate::asr::mimo::PROVIDER_ID {
return validate_mimo_asr_provider().await;
return validate_mimo_asr_provider(scope).await;
}
if active_asr == crate::asr::dashscope_multimodal::PROVIDER_ID {
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.unwrap_or_default();
crate::coordinator::validate_dashscope_multimodal_model(&model)?;
return validate_dashscope_multimodal_asr_provider().await;
return validate_dashscope_multimodal_asr_provider(scope).await;
}
if active_asr == crate::asr::elevenlabs::PROVIDER_ID {
return validate_elevenlabs_asr_provider().await;
return validate_elevenlabs_asr_provider(scope).await;
}
if active_asr == crate::asr::xfyun::PROVIDER_ID {
return validate_xfyun_asr_provider().await;
return validate_xfyun_asr_provider(scope).await;
}
// StepFun 一入口双协议:`*-stream` 模型走实时 WS 验证,其余走批式
// /audio/transcriptions(与 build 侧 resolve_effective_asr_provider 同判据)。
if active_asr == "stepfun" || active_asr == crate::asr::stepfun_realtime::PROVIDER_ID {
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if active_asr == crate::asr::stepfun_realtime::PROVIDER_ID
|| crate::coordinator::stepfun_model_is_stream(&model)
{
return validate_stepfun_realtime_asr_provider().await;
return validate_stepfun_realtime_asr_provider(scope).await;
}
}
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let config = read_openai_provider_config(scope)?;
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| "asrModelMissing".to_string())?;
@@ -344,14 +448,16 @@ async fn validate_asr_provider() -> Result<(), String> {
/// 讯飞 RTASR 验证:真连 + 500ms 静音 + 收尾。鉴权错误(10105 / 10110)在握手阶段
/// 即返回;纯静音会话服务端可能直接关闭且不返回任何 result(等价于「没说话」),
/// 这类 `NoFinalResult` 不算验证失败 —— 握手成功已经证明 AppID/APIKey 有效。
async fn validate_xfyun_asr_provider() -> Result<(), String> {
let app_id = CredentialsVault::get(CredentialAccount::XfyunAppId)
async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let app_id = scope
.get(CredentialAccount::XfyunAppId)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if app_id.trim().is_empty() {
return Err("讯飞 AppID 为空".to_string());
}
let api_key = CredentialsVault::get(CredentialAccount::XfyunApiKey)
let api_key = scope
.get(CredentialAccount::XfyunApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
@@ -376,17 +482,20 @@ async fn validate_xfyun_asr_provider() -> Result<(), String> {
/// StepFun 实时 WS 验证:真连 + session.update + 500ms 静音 + 收尾。
/// 协议无 finish 事件,收尾走静音帧 + 宽限期(纯静音会话以空文本成功返回,
/// 见 stepfun_realtime 模块注释),全程 ~2s。
async fn validate_stepfun_realtime_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
async fn validate_stepfun_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let api_key = scope
.get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
}
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
let endpoint = scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::stepfun_realtime::DEFAULT_MODEL.to_string());
@@ -410,9 +519,10 @@ async fn validate_stepfun_realtime_asr_provider() -> Result<(), String> {
.map_err(|e| e.to_string())
}
async fn validate_mimo_asr_provider() -> Result<(), String> {
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
async fn validate_mimo_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let config = read_openai_provider_config(scope)?;
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
@@ -427,18 +537,21 @@ async fn validate_mimo_asr_provider() -> Result<(), String> {
.map_err(|e| e.to_string())
}
async fn validate_elevenlabs_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
async fn validate_elevenlabs_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let api_key = scope
.get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "API Key 为空".to_string())?;
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
let base_url = scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string());
crate::endpoint_security::validate_http_endpoint(&base_url)
.map_err(|_| "endpointInvalid".to_string())?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string());
@@ -473,10 +586,11 @@ const DASHSCOPE_ASR_VALIDATE_SAMPLE_URL: &str =
const DASHSCOPE_ASR_VALIDATE_TIMEOUT_SECS: u64 = 120;
const DASHSCOPE_ASR_VALIDATE_POLL_SECS: u64 = 60;
async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> {
async fn validate_dashscope_multimodal_asr_provider(scope: &ProviderScope) -> Result<(), String> {
// 统一百炼复用配置中的区域/工作空间主机,并推导 multimodal 的 https 路径。
// 隐藏别名仍按原有完整 endpoint 读取。
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
@@ -484,11 +598,13 @@ async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> {
let protocol = crate::asr::dashscope_multimodal::protocol_for_model(&model)
.unwrap_or(crate::asr::dashscope_multimodal::DashScopeBatchProtocol::Multimodal);
let (api_key, base_url) = if crate::coordinator::unified_bailian_is_active() {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
let api_key = scope
.get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| "API Key 为空".to_string())?;
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
let endpoint = scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let endpoint_protocol = match protocol {
@@ -502,7 +618,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(scope)?;
(config.api_key, config.base_url)
};
if protocol == crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription {
@@ -557,8 +673,9 @@ async fn send_dashscope_multimodal_validation(
Ok(())
}
async fn validate_bailian_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
async fn validate_bailian_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let api_key = scope
.get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
@@ -566,7 +683,8 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {
}
// 已知残留(issue #609 F-01 孪生 gap):Bailian endpoint 走 `wss://`,与 http/https-only 的
// validate_http_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。
let stored_endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
let stored_endpoint = scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
@@ -584,11 +702,13 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {
if !crate::asr::bailian::endpoint_scheme_is_websocket(&endpoint) {
return Err("bailianEndpointSchemeInvalid".to_string());
}
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
let vocabulary_id = scope
.get(CredentialAccount::AsrVocabularyId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty());
let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new(
@@ -614,8 +734,9 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {
.map_err(|e| e.to_string())
}
async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
async fn validate_qwen3_realtime_asr_provider(scope: &ProviderScope) -> Result<(), String> {
let api_key = scope
.get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
@@ -623,7 +744,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> {
}
// 统一百炼保留配置中的区域/工作空间主机,并切换到 Qwen Realtime 路径。
let endpoint = if crate::coordinator::unified_bailian_is_active() {
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
let endpoint = scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.unwrap_or_default();
crate::coordinator::derive_bailian_endpoint(
@@ -631,7 +753,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> {
crate::coordinator::BailianEndpointProtocol::QwenRealtime,
)?
} else {
CredentialsVault::get(CredentialAccount::AsrEndpoint)
scope
.get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_ENDPOINT.to_string())
@@ -639,7 +762,8 @@ async fn validate_qwen3_realtime_asr_provider() -> Result<(), String> {
if !crate::asr::qwen_realtime::endpoint_scheme_is_secure_websocket(&endpoint) {
return Err("qwen3EndpointSchemeInvalid".to_string());
}
let model = CredentialsVault::get(CredentialAccount::AsrModel)
let model = scope
.get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::qwen_realtime::DEFAULT_MODEL.to_string());
@@ -1057,9 +1181,40 @@ mod tests {
asr_error_is_no_speech_rejection, fetch_provider_models, models_url,
provider_llm_error_message, provider_log_context, provider_request_error_message,
sanitized_provider_destination, send_dashscope_multimodal_validation, ProviderConfig,
ProviderScope,
};
use crate::endpoint_security::validate_http_endpoint;
#[test]
fn provider_scope_accepts_omni_without_channel() {
assert!(ProviderScope::new("omni", None).is_ok());
}
#[test]
fn provider_scope_rejects_channel_id_for_omni() {
let error = ProviderScope::new("omni", Some("channel-1".to_string()))
.err()
.expect("omni must remain outside channel storage");
assert_eq!(error, "omni provider does not support channel id");
}
#[test]
fn provider_scope_rejects_unknown_kind() {
let error = ProviderScope::new("unknown", None)
.err()
.expect("unknown provider kind must fail");
assert_eq!(error, "unknown provider kind: unknown");
}
#[test]
fn provider_scope_keeps_channel_ids_for_asr_and_llm() {
for kind in ["asr", "llm"] {
let scope = ProviderScope::new(kind, Some("channel-1".to_string()))
.expect("channel provider kind must remain supported");
assert_eq!(scope.channel.as_deref(), Some("channel-1"));
}
}
#[test]
fn silence_probe_content_rejection_is_not_a_credential_error() {
// StepFun 对静音探针的实测应答(2026-07):鉴权/模型都通过,只是探针
+18
View File
@@ -252,6 +252,15 @@ macro_rules! app_invoke_handler_desktop {
commands::read_credential,
commands::set_active_asr_provider,
commands::set_active_llm_provider,
commands::list_channels,
commands::create_channel,
commands::rename_channel,
commands::set_channel_provider_type,
commands::delete_channel_if_blank,
commands::delete_channel,
commands::set_channel_enabled,
commands::reorder_channels,
commands::record_channel_test,
commands::set_active_omni_provider,
commands::get_qa_hotkey_label,
commands::set_qa_hotkey,
@@ -368,6 +377,15 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::read_credential,
$crate::commands::set_active_asr_provider,
$crate::commands::set_active_llm_provider,
$crate::commands::list_channels,
$crate::commands::create_channel,
$crate::commands::rename_channel,
$crate::commands::set_channel_provider_type,
$crate::commands::delete_channel_if_blank,
$crate::commands::delete_channel,
$crate::commands::set_channel_enabled,
$crate::commands::reorder_channels,
$crate::commands::record_channel_test,
$crate::commands::set_active_omni_provider,
$crate::commands::validate_provider_credentials,
$crate::commands::list_provider_models,
File diff suppressed because it is too large. Load diff
@@ -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 <AndroidStepCard><AndroidPermissionsPanel mode="overlayConfig" /></AndroidStepCard>;
}
if (step === 'asr') {
return <ProvidersSection kind="asr" />;
return <ProvidersSection kind="asr" autoCreateWhenEmpty />;
}
return <ProvidersSection kind="llm" />;
return <ProvidersSection kind="llm" autoCreateWhenEmpty />;
}
function AndroidMicrophoneStep() {
+28
View File
@@ -864,7 +864,35 @@ export const en: typeof zhCN = {
startupAtBootDesc: 'Start OpenLess automatically when you sign in.',
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
},
channels: {
verify: 'Verify',
verifyHint: 'Makes one real API call to check this channel works right now',
errTimeout: 'timeout',
errNetwork: 'network',
errEndpoint: 'endpoint',
errGeneric: 'failed',
dragHint: 'Drag to change priority',
orderHint: 'Drag to reorder — the topmost one is used first. Disabled channels sink to the bottom.',
empty: 'No channels yet. Use "Add channel" below to create one.',
add: 'Add channel',
edit: 'Edit',
createTitle: 'Add channel',
editTitle: 'Edit channel',
providerLabel: 'Provider',
nameLabel: 'Name',
namePlaceholder: 'e.g. SiliconFlow — main key',
create: 'Create',
delete: 'Delete channel',
deleteConfirm: 'Deleting also clears the keys stored for this channel.',
confirmDelete: 'Delete',
justNow: 'just now',
minutesAgo: '{{count}}m ago',
hoursAgo: '{{count}}h ago',
daysAgo: '{{count}}d ago',
localEngineModelHint: 'Local engine models are downloaded and switched under Advanced → Local models.',
},
providers: {
localEngineNoCredentials: 'Local engines need no API key or endpoint.',
localModelLabel: 'Local model',
localModelEmpty: 'No local model downloaded yet',
appleSpeechLocalNote: 'Apple Speech uses the system built-in engine — no model selection needed.',
+28
View File
@@ -866,7 +866,35 @@ export const ja: typeof zhCN = {
startupAtBootDesc: 'ログイン時に OpenLess を自動起動。',
startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
},
channels: {
verify: '検証',
verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します',
errTimeout: 'タイムアウト',
errNetwork: 'ネットワーク',
errEndpoint: 'エンドポイント',
errGeneric: '失敗',
dragHint: 'ドラッグで優先順位を変更',
orderHint: 'ドラッグで並べ替え。一番上が優先で使われます。オフにしたチャネルは末尾に移動します。',
empty: 'チャネルがまだありません。下の「チャネルを追加」から作成してください。',
add: 'チャネルを追加',
edit: '編集',
createTitle: 'チャネルを追加',
editTitle: 'チャネルを編集',
providerLabel: 'プロバイダー',
nameLabel: '名前',
namePlaceholder: '例:SiliconFlow — メインキー',
create: '作成',
delete: 'チャネルを削除',
deleteConfirm: '削除するとこのチャネルに保存された鍵も消去されます。',
confirmDelete: '削除する',
justNow: 'たった今',
minutesAgo: '{{count}}分前',
hoursAgo: '{{count}}時間前',
daysAgo: '{{count}}日前',
localEngineModelHint: 'ローカルエンジンのモデルは「詳細 → ローカルモデル」でダウンロード・切り替えします。',
},
providers: {
localEngineNoCredentials: 'ローカルエンジンに API キーやエンドポイントは不要です。',
localModelLabel: 'ローカルモデル',
localModelEmpty: 'ローカルモデル未ダウンロード',
appleSpeechLocalNote: 'Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。',
+28
View File
@@ -866,7 +866,35 @@ export const ko: typeof zhCN = {
startupAtBootDesc: '로그인 시 OpenLess 자동 시작.',
startupAtBootError: '자동 시작 전환 실패: {{message}}',
},
channels: {
verify: '검증',
verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다',
errTimeout: '시간 초과',
errNetwork: '네트워크',
errEndpoint: '주소',
errGeneric: '실패',
dragHint: '드래그해서 우선순위 변경',
orderHint: '드래그해서 순서를 바꾸세요. 맨 위가 먼저 사용됩니다. 끈 채널은 맨 아래로 내려갑니다.',
empty: '아직 채널이 없습니다. 아래 "채널 추가"로 만들어 보세요.',
add: '채널 추가',
edit: '편집',
createTitle: '채널 추가',
editTitle: '채널 편집',
providerLabel: '공급자',
nameLabel: '이름',
namePlaceholder: '예: SiliconFlow — 메인 키',
create: '만들기',
delete: '채널 삭제',
deleteConfirm: '삭제하면 이 채널에 저장된 키도 함께 지워집니다.',
confirmDelete: '삭제',
justNow: '방금',
minutesAgo: '{{count}}분 전',
hoursAgo: '{{count}}시간 전',
daysAgo: '{{count}}일 전',
localEngineModelHint: '로컬 엔진 모델은 고급 → 로컬 모델에서 내려받고 전환합니다.',
},
providers: {
localEngineNoCredentials: '로컬 엔진은 API 키나 엔드포인트가 필요 없습니다.',
localModelLabel: '로컬 모델',
localModelEmpty: '아직 다운로드된 로컬 모델이 없습니다',
appleSpeechLocalNote: 'Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.',
+28
View File
@@ -862,7 +862,35 @@ export const zhCN = {
startupAtBootDesc: '登录系统时自动启动 OpenLess。',
startupAtBootError: '开机自启切换失败:{{message}}',
},
channels: {
verify: '验证',
verifyHint: '点一下真实调用一次接口,确认这张卡现在能用',
errTimeout: '超时',
errNetwork: '网络',
errEndpoint: '地址',
errGeneric: '失败',
dragHint: '按住拖动可调整优先级',
orderHint: '拖动排序,最上面的优先使用;关掉的渠道会自动排到末尾。',
empty: '还没有渠道。点下面的「添加渠道」新建一个。',
add: '添加渠道',
edit: '编辑',
createTitle: '添加渠道',
editTitle: '编辑渠道',
providerLabel: '供应商',
nameLabel: '名称',
namePlaceholder: '例如:硅基流动-主号',
create: '创建',
delete: '删除渠道',
deleteConfirm: '删除后该渠道保存的密钥也会一并清除。',
confirmDelete: '确认删除',
justNow: '刚刚',
minutesAgo: '{{count}} 分钟前',
hoursAgo: '{{count}} 小时前',
daysAgo: '{{count}} 天前',
localEngineModelHint: '本地引擎的模型下载与切换在「高级 → 本地模型」里。',
},
providers: {
localEngineNoCredentials: '本地引擎无需 API Key 与地址。',
localModelLabel: '本地模型',
localModelEmpty: '尚未下载本地模型',
appleSpeechLocalNote: 'Apple 语音识别使用系统内置引擎,无需选择模型。',
+28
View File
@@ -864,7 +864,35 @@ export const zhTW: typeof zhCN = {
startupAtBootDesc: '登錄系統時自動啓動 OpenLess。',
startupAtBootError: '開機自啓切換失敗:{{message}}',
},
channels: {
verify: '驗證',
verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用',
errTimeout: '逾時',
errNetwork: '網路',
errEndpoint: '網址',
errGeneric: '失敗',
dragHint: '按住拖曳可調整優先順序',
orderHint: '拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。',
empty: '還沒有渠道。點下面的「新增渠道」建立一個。',
add: '新增渠道',
edit: '編輯',
createTitle: '新增渠道',
editTitle: '編輯渠道',
providerLabel: '供應商',
nameLabel: '名稱',
namePlaceholder: '例如:矽基流動-主帳號',
create: '建立',
delete: '刪除渠道',
deleteConfirm: '刪除後該渠道儲存的金鑰也會一併清除。',
confirmDelete: '確認刪除',
justNow: '剛剛',
minutesAgo: '{{count}} 分鐘前',
hoursAgo: '{{count}} 小時前',
daysAgo: '{{count}} 天前',
localEngineModelHint: '本機引擎的模型下載與切換在「進階 → 本機模型」裡。',
},
providers: {
localEngineNoCredentials: '本機引擎不需要 API Key 與網址。',
localModelLabel: '本地模型',
localModelEmpty: '尚未下載本地模型',
appleSpeechLocalNote: 'Apple 語音辨識使用系統內建引擎,無需選擇模型。',
+55 -52
View File
@@ -1,35 +1,35 @@
import type { CredentialsStatus } from "../types"
import { invokeOrMock } from "./shared"
import { mockCredentialsStatus } from "./mock-data"
export interface ProviderCheckResult {
ok: boolean
}
export interface ProviderModelsResult {
models: string[]
}
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock(
"get_credentials",
undefined,
() => mockCredentialsStatus,
)
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock("set_credential", { account, value, provider }, () => undefined)
}
export function setActiveAsrProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_asr_provider",
{ provider },
() => undefined,
)
}
import type { CredentialsStatus } from "../types"
import { invokeOrMock } from "./shared"
import { mockCredentialsStatus } from "./mock-data"
export interface ProviderCheckResult {
ok: boolean
}
export interface ProviderModelsResult {
models: string[]
}
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock(
"get_credentials",
undefined,
() => mockCredentialsStatus,
)
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock("set_credential", { account, value, provider }, () => undefined)
}
export function setActiveAsrProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_asr_provider",
{ provider },
() => undefined,
)
}
export function setActiveLlmProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_llm_provider",
@@ -46,29 +46,32 @@ export function setActiveOmniProvider(provider: string): Promise<void> {
)
}
export function readCredential(account: string, provider?: string): Promise<string | null> {
return invokeOrMock<string | null>(
"read_credential",
{ account, provider },
() => null,
)
}
export function readCredential(account: string, provider?: string): Promise<string | null> {
return invokeOrMock<string | null>(
"read_credential",
{ account, provider },
() => null,
)
}
/** `channelId` 省略时测当前生效的渠道;卡片上的「测试连通」会带上那张卡片的 id。 */
export function validateProviderCredentials(
kind: "llm" | "asr" | "omni",
channelId?: string,
): Promise<ProviderCheckResult> {
return invokeOrMock("validate_provider_credentials", { kind }, () => ({
ok: true,
}))
}
return invokeOrMock("validate_provider_credentials", { kind, channelId }, () => ({
ok: true,
}))
}
export function listProviderModels(
kind: "llm" | "asr" | "omni",
channelId?: string,
): Promise<ProviderModelsResult> {
return invokeOrMock("list_provider_models", { kind }, () => ({
models:
kind === "llm"
? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"]
: ["whisper-1"],
}))
}
return invokeOrMock("list_provider_models", { kind, channelId }, () => ({
models:
kind === "llm"
? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"]
: ["whisper-1"],
}))
}
+176
View File
@@ -0,0 +1,176 @@
// 渠道卡片的 IPC 封装。
//
// 一张卡片 = 一份可命名、可排序、可开关的供应商配置;同一家厂商可以有多张卡片
// (多把 key)。列表里**第一个启用的**就是当前生效的渠道 —— 后端不另存"当前选中",
// 排序即优先级(见 docs/provider-channels-plan.md)。
//
// 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。
import { invokeOrMock } from "./shared"
export type ChannelKind = "llm" | "asr"
export interface ChannelTestResult {
ok: boolean
latencyMs: number | null
/** Unix 秒(后端时钟,前端不要自己生成)。 */
at: number
error: string | null
}
export interface Channel {
id: string
/** 用户取的名字;空串表示未命名,由 UI 回落到 preset 显示名。 */
name: string
/** 厂商 id —— 决定协议与表单形状,与 id 相互独立。 */
providerType: string
enabled: boolean
order: number
lastTest: ChannelTestResult | null
}
// 浏览器(非 Tauri)下的样例数据,让 `npm run dev` 能预览列表的四种状态:
// 生效中 / 备用 / 测试失败标红 / 已关闭沉底。
const mockChannels: Record<ChannelKind, Channel[]> = {
llm: [
{
id: "siliconflow",
name: "硅基流动-主号",
providerType: "siliconflow",
enabled: true,
order: 0,
lastTest: { ok: true, latencyMs: 284, at: Math.floor(Date.now() / 1000) - 90, error: null },
},
{
id: "ark",
name: "",
providerType: "ark",
enabled: true,
order: 1,
lastTest: null,
},
{
id: "openai",
name: "OpenAI-备用",
providerType: "openai",
enabled: false,
order: 2,
lastTest: { ok: false, latencyMs: null, at: Math.floor(Date.now() / 1000) - 3600, error: "providerHttpStatus:401" },
},
],
asr: [
{
id: "volcengine",
name: "",
providerType: "volcengine",
enabled: true,
order: 0,
lastTest: { ok: true, latencyMs: 143, at: Math.floor(Date.now() / 1000) - 20, error: null },
},
{
id: "groq",
name: "Groq-白嫖号",
providerType: "groq",
enabled: true,
order: 1,
lastTest: null,
},
],
}
export function listChannels(kind: ChannelKind): Promise<Channel[]> {
return invokeOrMock("list_channels", { kind }, () => mockChannels[kind])
}
/** 返回后端分配的渠道 id。 */
export function createChannel(
kind: ChannelKind,
providerType: string,
name: string,
): Promise<string> {
return invokeOrMock(
"create_channel",
{ kind, providerType, name },
() => providerType,
)
}
/** 在已建好的草稿卡片上换供应商(单弹窗添加流程的常规操作)。 */
export function setChannelProviderType(
kind: ChannelKind,
id: string,
providerType: string,
): Promise<void> {
return invokeOrMock(
"set_channel_provider_type",
{ kind, id, providerType },
() => undefined,
)
}
/** 关闭添加弹窗时回收没填任何东西的草稿;返回是否真的删了。 */
export function deleteChannelIfBlank(
kind: ChannelKind,
id: string,
): Promise<boolean> {
return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true)
}
export function renameChannel(
kind: ChannelKind,
id: string,
name: string,
): Promise<void> {
return invokeOrMock("rename_channel", { kind, id, name }, () => undefined)
}
export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
return invokeOrMock("delete_channel", { kind, id }, () => undefined)
}
export function setChannelEnabled(
kind: ChannelKind,
id: string,
enabled: boolean,
): Promise<void> {
return invokeOrMock(
"set_channel_enabled",
{ kind, id, enabled },
() => undefined,
)
}
/** ids 是拖拽后的完整顺序;后端会把未提及的渠道排到末尾。 */
export function reorderChannels(
kind: ChannelKind,
ids: string[],
): Promise<void> {
return invokeOrMock("reorder_channels", { kind, ids }, () => {
// mock 也要真的重排:否则浏览器预览里松手后顺序被 listChannels 拉回原样,
// 看着就像"拖拽坏了",而真机是好的。
const list = mockChannels[kind]
const ordered = ids
.map(id => list.find(c => c.id === id))
.filter((c): c is Channel => Boolean(c))
const rest = list.filter(c => !ids.includes(c.id))
mockChannels[kind] = [...ordered, ...rest].map((c, index) => ({
...c,
order: index,
}))
return undefined
})
}
export function recordChannelTest(
kind: ChannelKind,
id: string,
ok: boolean,
latencyMs: number | null,
error: string | null,
): Promise<void> {
return invokeOrMock(
"record_channel_test",
{ kind, id, ok, latencyMs, error },
() => undefined,
)
}
+14
View File
@@ -33,6 +33,20 @@ export {
listProviderModels,
} from "./asr-credentials"
// channels(渠道卡片)
export type { Channel, ChannelKind, ChannelTestResult } from "./channels"
export {
listChannels,
createChannel,
setChannelProviderType,
deleteChannelIfBlank,
renameChannel,
deleteChannel,
setChannelEnabled,
reorderChannels,
recordChannelTest,
} from "./channels"
// history
export {
listHistory,
+33 -1
View File
@@ -16,7 +16,14 @@ import {
type ReactNode,
} from "react"
import { useTranslation } from "react-i18next"
import { isTauri, setActiveAsrProvider } from "../../lib/ipc"
import {
createChannel,
isTauri,
listChannels,
reorderChannels,
setActiveAsrProvider,
setChannelEnabled,
} from "../../lib/ipc"
import {
FOUNDRY_LOCAL_ASR_MODELS,
SHERPA_ONNX_ASR_MODELS,
@@ -104,6 +111,28 @@ import {
} from "./components"
import type { RemoteSize } from "./types"
// 渠道化后「当前生效」由渠道列表第一个启用卡派生(见 docs/provider-channels-plan.md)。
// 本页直接 setActiveAsrProvider 激活本地引擎,会被 save_credentials 里的
// sync_active_channels 覆盖回列表第一张云端卡——激活前确保本地引擎卡存在、
// 已启用且置顶,让两处心智一致。
async function ensureLocalAsrChannel(providerType: string): Promise<void> {
let channels = await listChannels("asr")
if (!channels.some(c => c.id === providerType)) {
await createChannel("asr", providerType, "")
channels = await listChannels("asr")
}
const current = channels.find(c => c.id === providerType)
if (current && !current.enabled) {
await setChannelEnabled("asr", providerType, true)
}
if (channels[0]?.id !== providerType) {
await reorderChannels(
"asr",
[providerType, ...channels.filter(c => c.id !== providerType).map(c => c.id)],
)
}
}
// Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows),
// 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、
// 事件订阅都必须按 OS 隔离,避免 macOS / Linux 用户看到 Windows 专属的 UI。
@@ -974,6 +1003,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
try {
setError(null)
await setFoundryLocalAsrModel(alias)
await ensureLocalAsrChannel("foundry-local-whisper")
await setActiveAsrProvider("foundry-local-whisper")
await syncFoundryPrefs(alias, true)
foundrySelectionDirty.current = false
@@ -1114,6 +1144,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const activateSherpaProvider = async (modelAlias: SherpaOnnxModelAlias) => {
await setSherpaOnnxAsrModel(modelAlias)
await ensureLocalAsrChannel("sherpa-onnx-local")
await setActiveAsrProvider("sherpa-onnx-local")
await syncSherpaPrefs(modelAlias, true)
sherpaSelectionDirty.current = false
@@ -1472,6 +1503,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const handleTest = async (modelId: string) => {
try {
await setLocalAsrActiveModel(modelId)
await ensureLocalAsrChannel("local-qwen3")
await setActiveAsrProvider("local-qwen3")
await updatePrefs((current) =>
current.activeAsrProvider === "local-qwen3" &&
@@ -0,0 +1,46 @@
import type { OS } from '../../components/WindowChrome';
import { presetsFor, shouldRecycleDraft } from './ChannelList';
const localProviders = [
'local-qwen3',
'apple-speech',
'foundry-local-whisper',
'sherpa-onnx-local',
] as const;
const expectedByPlatform: Record<OS, readonly string[]> = {
mac: ['local-qwen3', 'apple-speech'],
win: ['foundry-local-whisper', 'sherpa-onnx-local'],
linux: [],
android: [],
};
for (const os of Object.keys(expectedByPlatform) as OS[]) {
const ids = new Set(presetsFor('asr', os).map(preset => preset.id));
const expected = new Set(expectedByPlatform[os]);
for (const provider of localProviders) {
if (ids.has(provider) !== expected.has(provider)) {
throw new Error(`${provider} visibility is incorrect on ${os}`);
}
}
if (!ids.has('volcengine')) {
throw new Error(`cloud ASR providers must remain visible on ${os}`);
}
if (ids.has('bailian-qwen3-realtime') || ids.has('bailian-fun-asr-flash')) {
throw new Error(`legacy Bailian aliases must remain hidden on ${os}`);
}
}
if (!shouldRecycleDraft('draft-1', false)) {
throw new Error('an untouched draft must be recycled');
}
if (shouldRecycleDraft('draft-1', true)) {
throw new Error('a touched draft must be preserved');
}
if (shouldRecycleDraft(null, false)) {
throw new Error('an existing channel must never enter draft cleanup');
}
console.log('ChannelList platform filtering and draft lifecycle tests passed');
@@ -0,0 +1,866 @@
// 渠道卡片列表 —— 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, type OS } from '../../components/WindowChrome';
import {
createChannel,
deleteChannel,
deleteChannelIfBlank,
listChannels,
readCredential,
recordChannelTest,
renameChannel,
reorderChannels,
setChannelEnabled,
setChannelProviderType,
setCredential,
validateProviderCredentials,
type Channel,
} from '../../lib/ipc';
import { emitSaved } from '../../lib/savedEvent';
import { useMobileLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import {
ChannelCredentialFields,
LLM_PRESETS,
LOCAL_ASR_PROVIDER_IDS,
OmniChannelSection,
} from './ProvidersSection';
import { ASR_PRESETS, inputStyle, SectionTitle, Toggle } from './shared';
type ChannelKind = 'llm' | 'asr';
interface PresetOption {
id: string;
nameKey: string;
}
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
* 固定卡片,而是和云端厂商一样由用户添加,只是编辑时没有 key / 地址字段。 */
export function presetsFor(kind: ChannelKind, os: OS): PresetOption[] {
if (kind === 'llm') {
return LLM_PRESETS.map(p => ({ id: p.id, nameKey: p.nameKey }));
}
return ASR_PRESETS.filter(p => {
// 本地引擎严格按其实际支持的平台暴露;Linux / Android 不展示桌面专有实现。
if (p.id === 'local-qwen3' || p.id === 'apple-speech') return os === 'mac';
if (p.id === 'foundry-local-whisper' || p.id === 'sherpa-onnx-local') {
return os === 'win';
}
// 百炼的两个旧 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 }));
}
/** 只有从未发生用户交互的新建草稿才允许走空白回收。 */
export function shouldRecycleDraft(draftId: string | null, touched: boolean): boolean {
return draftId != null && !touched;
}
function presetLabel(
kind: ChannelKind,
providerType: string,
t: ReturnType<typeof useTranslation>['t'],
): string {
const list: readonly { id: string; nameKey: string }[] =
kind === 'llm' ? LLM_PRESETS : ASR_PRESETS;
const preset = list.find(p => p.id === providerType);
return preset
? t(`settings.providers.presets.${preset.nameKey}`)
: providerType;
}
/** 卡片上模型那一行读的凭据账户 —— 与 ChannelCredentialFields 里保持一致。 */
function modelAccountFor(kind: ChannelKind): string {
return kind === 'llm' ? 'ark.model_id' : 'asr.model';
}
/**
* 把后端的错误串压成按钮上放得下的短标签,且要**能指导行动**:
* 401 是 key 不对、429 是被限流等会儿再说、超时是网络——用户看到才知道该改什么。
*/
function shortErrorLabel(
raw: string | null,
t: ReturnType<typeof useTranslation>['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<typeof useTranslation>['t']): string {
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at);
if (seconds < 60) return t('settings.channels.justNow');
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return t('settings.channels.minutesAgo', { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t('settings.channels.hoursAgo', { count: hours });
return t('settings.channels.daysAgo', { count: Math.floor(hours / 24) });
}
export function ChannelList({
kind,
autoCreateWhenEmpty = false,
}: {
kind: ChannelKind;
/** 新手引导用:列表为空时直接摊开添加表单,别让新用户对着空列表和一个加号发呆。 */
autoCreateWhenEmpty?: boolean;
}) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const os = detectOS();
const presets = presetsFor(kind, os);
const [channels, setChannels] = useState<Channel[]>([]);
const [models, setModels] = useState<Record<string, string>>({});
const [loaded, setLoaded] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
/** 新建时先落一张草稿卡片(凭据必须按渠道 id 写入),弹窗直接编辑它。 */
const [draftId, setDraftId] = useState<string | null>(null);
/** 同步 ref 避免 blur 保存与关闭弹窗之间的 state 调度竞态。 */
const draftTouchedRef = useRef(false);
const [creatingBusy, setCreatingBusy] = useState(false);
// 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。
const autoOpenedRef = useRef(false);
const refresh = useCallback(async () => {
try {
const list = await listChannels(kind);
setChannels(list);
setLoaded(true);
// 卡片上要显示每张卡当前的模型名 —— 凭据按渠道隔离,只能逐个读。
// 渠道数量是个位数,并发读一轮的开销可以忽略。
const account = modelAccountFor(kind);
const entries = await Promise.all(
list.map(async channel => {
try {
return [channel.id, (await readCredential(account, channel.id)) ?? ''] as const;
} catch {
return [channel.id, ''] as const;
}
}),
);
setModels(Object.fromEntries(entries));
} catch (error) {
console.error('[channels] failed to load', error);
setLoaded(true);
}
}, [kind]);
useEffect(() => {
void refresh();
}, [refresh]);
// ── 添加:一步到位 ──
// 点「添加渠道」直接开编辑弹窗(供应商、名字、密钥、测试都在里面)。草稿卡片在
// 后台先建出来只是因为凭据要按渠道 id 落盘;用户完全没有交互就关掉时才会被回收。
// 一旦改过任何字段就保留,避免 blur/debounce 保存与关闭流程竞争删除卡片。
const startCreate = useCallback(async () => {
if (creatingBusy) return;
setCreatingBusy(true);
draftTouchedRef.current = false;
try {
const id = await createChannel(kind, presets[0]?.id ?? '', '');
setDraftId(id);
await refresh();
} catch (error) {
console.error('[channels] create failed', error);
emitSaved('failed', t('common.operationFailed'));
} finally {
setCreatingBusy(false);
}
}, [creatingBusy, kind, presets, refresh, t]);
useEffect(() => {
if (!autoCreateWhenEmpty || !loaded || autoOpenedRef.current) return;
if (channels.length === 0) {
autoOpenedRef.current = true;
void startCreate();
}
}, [autoCreateWhenEmpty, loaded, channels.length, startCreate]);
// 生效中的那张 = 第一个启用的(列表已按 order 排好)。
const activeId = channels.find(c => c.enabled)?.id ?? null;
// ── 卡片上的验证 ──
// 只在用户点的时候跑:验证是**真实的 API 调用**(LLM 走一次真的润色请求、ASR 会传
// 一段静音音频上去)。做成打开设置就全部自动验一遍的话,等于每次开设置都按卡片数
// 烧一遍额度,还容易把自己撞进限流。
const [testingIds, setTestingIds] = useState<Record<string, boolean>>({});
/** 刚验通过的短暂高亮(id → 延迟 ms),几秒后落回常驻的灰色数字。 */
const [justPassed, setJustPassed] = useState<Record<string, number>>({});
const runTest = async (channel: Channel) => {
if (testingIds[channel.id]) return;
setTestingIds(prev => ({ ...prev, [channel.id]: true }));
const started = performance.now();
try {
const result = await validateProviderCredentials(kind, channel.id);
const latency = Math.round(performance.now() - started);
await recordChannelTest(
kind,
channel.id,
result.ok,
result.ok ? latency : null,
result.ok ? null : 'validateFailed',
);
if (result.ok) {
setJustPassed(prev => ({ ...prev, [channel.id]: latency }));
window.setTimeout(() => {
setJustPassed(prev => {
const next = { ...prev };
delete next[channel.id];
return next;
});
}, 3000);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
try {
await recordChannelTest(kind, channel.id, false, null, message);
} catch (recordError) {
console.error('[channels] failed to record test', recordError);
}
} finally {
setTestingIds(prev => ({ ...prev, [channel.id]: false }));
await refresh();
}
};
const onToggle = async (channel: Channel) => {
emitSaved('saving', t('common.saving'));
try {
await setChannelEnabled(kind, channel.id, !channel.enabled);
await refresh();
emitSaved('saved', t('common.saved'));
} catch (error) {
console.error('[channels] toggle failed', error);
emitSaved('failed', t('common.operationFailed'));
}
};
// ── 拖拽排序 ──
// 用 pointer 事件手写,**不用 HTML5 draggable**:Tauri 的 webview 默认开着
// dragDropEnabled,会把 dragstart/drop 当成文件拖放吞掉,`draggable` 在打包后的
// app 里根本不触发(浏览器里却是好的,最容易漏测)。pointer 方案还顺带让
// Windows 与 Android 的行为保持一致。
const rowsRef = useRef(new Map<string, HTMLDivElement>());
const channelsRef = useRef<Channel[]>([]);
const dragIdRef = useRef<string | null>(null);
const orderAtDragStartRef = useRef<string[]>([]);
const [draggingId, setDraggingId] = useState<string | null>(null);
useEffect(() => {
channelsRef.current = channels;
}, [channels]);
const dragCleanupRef = useRef<(() => void) | null>(null);
/** 指针移到哪张卡片上,就把被拖的那张插到那个位置 —— 卡片实时跟手。 */
const moveDragTo = (pointerY: number) => {
const dragId = dragIdRef.current;
if (!dragId) return;
let targetId: string | null = null;
for (const [id, element] of rowsRef.current) {
const rect = element.getBoundingClientRect();
if (pointerY >= rect.top && pointerY <= rect.bottom) {
targetId = id;
break;
}
}
if (!targetId || targetId === dragId) return;
setChannels(prev => {
const from = prev.findIndex(c => c.id === dragId);
const to = prev.findIndex(c => c.id === targetId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...prev];
next.splice(to, 0, next.splice(from, 1)[0]);
return next;
});
};
/// 拖拽刚结束时浏览器还会补一个 click。设置弹窗的遮罩层上挂着 onClick={onClose},
/// 这个补发的 click 会把整个设置面板关掉(拖一次卡片、设置就没了)。在捕获阶段
/// 吞掉紧随其后的那一个 click,200ms 内没等到就撤掉监听。
const swallowNextClick = () => {
const handler = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener('click', handler, { capture: true, once: true });
window.setTimeout(() => {
window.removeEventListener('click', handler, { capture: true });
}, 200);
};
const endDrag = async () => {
dragCleanupRef.current?.();
dragCleanupRef.current = null;
const dragId = dragIdRef.current;
dragIdRef.current = null;
setDraggingId(null);
if (!dragId) return;
swallowNextClick();
const ids = channelsRef.current.map(c => c.id);
const before = orderAtDragStartRef.current;
if (ids.length === before.length && ids.every((id, index) => id === before[index])) {
return; // 顺序没变,不打扰后端
}
try {
await reorderChannels(kind, ids);
await refresh();
emitSaved('saved', t('common.saved'));
} catch (error) {
console.error('[channels] reorder failed', error);
emitSaved('failed', t('common.operationFailed'));
await refresh();
}
};
// 刻意**不用** setPointerCapture:它会把后续事件重定向到手柄,浏览器补发的 click
// 于是落到设置弹窗的遮罩上,一拖就把设置关了。改用 window 级监听,事件目标不变。
const onDragHandleDown = (event: React.PointerEvent<HTMLElement>, 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 markDraftTouched = () => {
if (draftId != null) draftTouchedRef.current = true;
};
const closeModal = async () => {
const id = draftId;
const touched = draftTouchedRef.current;
setDraftId(null);
setEditingId(null);
draftTouchedRef.current = false;
if (shouldRecycleDraft(id, touched)) {
// 只回收从未发生用户交互的草稿;一旦用户改过任何内容,异步保存无论成功与否
// 都不得与关闭流程竞争删除这张卡片。
try {
await deleteChannelIfBlank(kind, id!);
} catch (error) {
console.error('[channels] blank cleanup failed', error);
}
}
await refresh();
};
return (
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>
{t(kind === 'llm' ? 'settings.providers.llmTitle' : 'settings.providers.asrTitle')}
</SectionTitle>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.channels.orderHint')}
</div>
{loaded && channels.length === 0 && (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-4)', padding: '10px 0 14px', lineHeight: 1.6 }}>
{t('settings.channels.empty')}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 (
<div
key={channel.id}
ref={element => {
if (element) rowsRef.current.set(channel.id, element);
else rowsRef.current.delete(channel.id);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
// 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。
padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px',
borderRadius: 10,
border: '0.5px solid var(--ol-line-strong)',
// 「当前在用」只用一条竖条表达位置,**不用绿色也不用文字**:
// 绿色和「生效中」会被读成"这张是健康的",可它只代表排在最前面 ——
// 一张 key 已经失效的卡片照样排第一。健康与否只有验证说了算。
borderLeft: isActive
? '2.5px solid var(--ol-blue)'
: '0.5px solid var(--ol-line-strong)',
background: channel.enabled ? 'var(--ol-surface)' : 'var(--ol-bg-2, transparent)',
opacity: draggingId === channel.id ? 0.55 : channel.enabled ? 1 : 0.62,
boxShadow: draggingId === channel.id ? '0 6px 18px rgba(0,0,0,0.14)' : undefined,
transition: draggingId ? undefined : 'opacity 0.16s var(--ol-motion-quick)',
}}
>
<span
onPointerDown={e => onDragHandleDown(e, channel.id)}
onClick={e => e.stopPropagation()}
title={t('settings.channels.dragHint')}
aria-label={t('settings.channels.dragHint')}
style={{
color: 'var(--ol-ink-4)',
fontSize: 13,
flexShrink: 0,
cursor: draggingId === channel.id ? 'grabbing' : 'grab',
// 触摸设备上按住手柄不要变成页面滚动。
touchAction: 'none',
padding: '2px 2px',
userSelect: 'none',
}}
>
⠿
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</span>
{/* minHeight 保证「未命名 + 没验证过」的卡片不会比别的矮一截,
列表高度参差看起来像坏了。 */}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 2, minHeight: 15, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */}
{channel.name.trim() && <span>{presetLabel(kind, channel.providerType, t)}</span>}
{model && <span style={{ fontFamily: 'var(--ol-font-mono)' }}>{model}</span>}
{/* 验证结果什么时候来的 —— 让"这条结论会过期"这件事可见。 */}
{channel.lastTest && <span>{relativeTime(channel.lastTest.at, t)}</span>}
</div>
</div>
<VerifyButton
channel={channel}
testing={Boolean(testingIds[channel.id])}
justPassedMs={justPassed[channel.id]}
onRun={() => void runTest(channel)}
t={t}
/>
<Toggle on={channel.enabled} onToggle={() => void onToggle(channel)} />
<button
onClick={() => setEditingId(channel.id)}
title={t('settings.channels.edit')}
aria-label={t('settings.channels.edit')}
style={iconBtn}
>
<Icon name="chevRight" size={13} />
</button>
</div>
);
})}
</div>
<button
onClick={() => void startCreate()}
disabled={creatingBusy}
style={{ ...addBtn, marginTop: channels.length ? 10 : 0 }}
>
+ {t('settings.channels.add')}
</button>
{editingChannel && (
<ChannelModal
kind={kind}
channel={editingChannel}
presets={presets}
isDraft={isDraft}
mobile={mobile}
onClose={() => void closeModal()}
onChanged={refresh}
onUserMutation={markDraftTouched}
/>
)}
</Card>
);
}
/**
* 卡片上的验证按钮 —— **按钮自己就是结果容器**,不给结果另找地方摆文字。
*
* 通过时只显示延迟数字:它既说明"通了",又带信息量(一眼看出哪张快);
* 再写一句"验证通过"是废话还占地方。失败则必须给出能指导行动的短标签
* (401 改 key / 429 等会儿 / 超时查网络)。
*
* 宽度固定:让 `验证` → `284ms` → `✗ 401` 的文字变化不会把开关和箭头挤来挤去。
*/
function VerifyButton({
channel,
testing,
justPassedMs,
onRun,
t,
}: {
channel: Channel;
testing: boolean;
justPassedMs?: number;
onRun: () => void;
t: ReturnType<typeof useTranslation>['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 (
<button
onClick={onRun}
disabled={testing}
title={t('settings.channels.verifyHint')}
style={{
width: 76,
height: 28,
flexShrink: 0,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 7,
background: 'var(--ol-surface)',
color,
cursor: 'default',
fontSize: 11.5,
fontWeight: 500,
opacity: stale && !testing ? 0.75 : 1,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{label}
</button>
);
}
/**
* 「服务 → AI 提供商」面板:LLM 与 ASR 两张渠道列表。
*
* 保留 `ProvidersSection` 这个名字与 `kind` 签名,让设置页 tabs 与新手引导的调用点
* 不用改。渠道化之后它只是两个 <ChannelList> 的容器。
*/
export function ProvidersSection({
kind = 'all',
autoCreateWhenEmpty = false,
}: {
kind?: 'all' | 'llm' | 'asr';
autoCreateWhenEmpty?: boolean;
} = {}) {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
// 多模态管线接管(issue #902):多模态模式下隐藏传统 llm/asr 渠道列表,
// 凭据两套并存但停用,切回即恢复(与合并前 beta 语义一致)。
const multimodalMode =
prefs?.multimodalPipelineEnabled === true && prefs?.pipelineMode === 'multimodal';
return (
<>
{kind === 'all' && <OmniChannelSection />}
{kind === 'all' && !multimodalMode && (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.providers.credentialStorageNotice')}
</div>
)}
{!multimodalMode && (kind === 'all' || kind === 'llm') && (
<ChannelList kind="llm" autoCreateWhenEmpty={autoCreateWhenEmpty} />
)}
{!multimodalMode && (kind === 'all' || kind === 'asr') && (
<ChannelList kind="asr" autoCreateWhenEmpty={autoCreateWhenEmpty} />
)}
</>
);
}
/**
* 添加与编辑共用的同一个弹窗 —— 供应商、名字、凭据、测试连通都在这一屏里。
*
* 刻意不做「先创建、再填凭据」的两步:那只是实现上需要先有渠道 id 才能写凭据,
* 不该变成用户多点一次。
*/
function ChannelModal({
kind,
channel,
presets,
isDraft,
mobile,
onClose,
onChanged,
onUserMutation,
}: {
kind: ChannelKind;
channel: Channel;
presets: PresetOption[];
/** 新建流程中的草稿卡片:标题用「添加渠道」,未触碰时允许回收。 */
isDraft: boolean;
mobile: boolean;
onClose: () => void;
onChanged: () => void | Promise<void>;
/** 用户对草稿做了有意义的操作;必须在异步写入前同步触发。 */
onUserMutation: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [providerType, setProviderType] = useState(channel.providerType);
const [confirmDelete, setConfirmDelete] = useState(false);
const saveName = async () => {
if (name.trim() === channel.name.trim()) return;
try {
await renameChannel(kind, channel.id, name.trim());
await onChanged();
} catch (error) {
console.error('[channels] rename failed', error);
emitSaved('failed', t('common.operationFailed'));
}
};
// 换供应商后把 preset 默认 endpoint / model 写进**空槽**(不覆盖用户已填的自定义值)。
// 渠道化后每张卡凭据独立,这里按卡片 id 读写;Codex OAuth / 本地引擎 / 自定义
// OpenAI 兼容(baseUrl/model 为空)自然跳过。失败只记日志,不影响换厂商本身。
const fillProviderDefaults = async (next: string) => {
try {
if (kind === 'llm') {
const preset = LLM_PRESETS.find(p => p.id === next);
if (!preset || preset.id === 'custom' || preset.id === 'codex_oauth') return;
if (preset.baseUrl && !(await readCredential('ark.endpoint', channel.id))?.trim()) {
await setCredential('ark.endpoint', preset.baseUrl, channel.id);
}
if (
preset.modelPlaceholder &&
!(await readCredential('ark.model_id', channel.id))?.trim()
) {
await setCredential('ark.model_id', preset.modelPlaceholder, channel.id);
}
return;
}
const preset = ASR_PRESETS.find(p => p.id === next);
if (!preset) return;
if (preset.baseUrl && !(await readCredential('asr.endpoint', channel.id))?.trim()) {
await setCredential('asr.endpoint', preset.baseUrl, channel.id);
}
if (preset.model && !(await readCredential('asr.model', channel.id))?.trim()) {
await setCredential('asr.model', preset.model, channel.id);
}
} catch (error) {
console.error('[channels] failed to fill provider defaults', error);
}
};
const changeProvider = async (next: string) => {
const previous = providerType;
onUserMutation();
setProviderType(next);
try {
await setChannelProviderType(kind, channel.id, next);
await fillProviderDefaults(next);
await onChanged();
} catch (error) {
console.error('[channels] change provider failed', error);
setProviderType(previous);
emitSaved('failed', t('common.operationFailed'));
}
};
const remove = async () => {
try {
await deleteChannel(kind, channel.id);
emitSaved('saved', t('common.saved'));
onClose();
} catch (error) {
console.error('[channels] delete failed', error);
emitSaved('failed', t('common.operationFailed'));
}
};
const isLocalEngine = LOCAL_ASR_PROVIDER_IDS.includes(providerType);
return (
<Modal onClose={onClose} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)', marginBottom: 14 }}>
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
</div>
<label style={fieldLabel}>{t('settings.channels.providerLabel')}</label>
<SelectLite
value={providerType}
onChange={next => 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 }}
/>
<label style={fieldLabel}>{t('settings.channels.nameLabel')}</label>
<input
value={name}
onChange={e => {
onUserMutation();
setName(e.target.value);
}}
onBlur={() => void saveName()}
placeholder={t('settings.channels.namePlaceholder')}
style={{ ...inputStyle, width: '100%', marginBottom: 14 }}
/>
{/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */}
<ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
channelId={channel.id}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
{isLocalEngine && (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginTop: 6 }}>
{t('settings.channels.localEngineModelHint')}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'space-between', marginTop: 20, alignItems: 'center' }}>
{confirmDelete ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{t('settings.channels.deleteConfirm')}
</span>
<button onClick={() => void remove()} style={dangerBtn}>
{t('settings.channels.confirmDelete')}
</button>
<button onClick={() => setConfirmDelete(false)} style={ghostBtn}>{t('common.cancel')}</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)} style={ghostBtn}>
{t('settings.channels.delete')}
</button>
)}
<button onClick={onClose} style={primaryBtn}>{t('common.close')}</button>
</div>
</Modal>
);
}
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)',
};
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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';