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:
21 files changed
+3720
-932
No files matched your search
@@ -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):鉴权/模型都通过,只是探针
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。',
|
||||
|
||||
@@ -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 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.',
|
||||
|
||||
@@ -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 语音识别使用系统内置引擎,无需选择模型。',
|
||||
|
||||
@@ -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 語音辨識使用系統內建引擎,無需選擇模型。',
|
||||
|
||||
@@ -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"],
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user