Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions docs/provider-channels-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# 供应商渠道卡片化 实施计划

> 状态:设计讨论中(尚未动手)
> 日期: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, // uuid,取代 preset id 作为 map key
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` = 新 uuid,`provider_type` = 原 map key,`name` = `displayName` 或 preset 显示名
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 分支渲染凭据字段的逻辑(火山双鉴权模式、讯飞双字段、百炼词表等)
- 本地引擎卡片的编辑弹窗内嵌 `<LocalAsr embedded />`(`LocalAsr/index.tsx:122`)

**新手引导**:列表为空时直接摊开添加表单,跳过空态与加号,省一次点击。

## 8. 分期

| 期 | 内容 | 可否独立发布 |
| --- | --- | --- |
| **P0** | 渠道数据模型 + 迁移 + 卡片 UI + 拖拽排序 + 测试连通。**不做重试** | ✅ 独立故事:「我有两把 key,想随手切」 |
| | ↑ 已完成。**此时多渠道的价值是"存档 + 手动切换",不是自动容错**:排在第二的卡片永远不会被自动用上,要用得手动拖到第一位。 | |
| **P1** | 凭据显式化重构:`CredentialsVault::get(...)` → 上层解析 `ResolvedChannel` 显式下传 | ❌ 纯重构,无用户可见变化,P2 前提 |
| **P2** | 重试 + 故障转移 + 429 冷却 + 超时下调 + 全挂兜底 | ✅ |

P1 是本需求最大的单块工作量,比卡片 UI 大得多。P1 + P2 合成第二个 PR。

## 9. 待确认

- [ ] 拖拽排序用什么实现(现有依赖里有没有可用的,还是手写 HTML5 drag)
- [ ] 卡片列表的移动端(Android)形态
- [ ] Android 的 `android_credentials.rs` 加密信封是否需要同步改版本号
- [ ] P0 的验收判据(建议:能建 3 张同供应商不同 key 的卡片、重启后顺序与内容不丢、拖拽后生效的是第一张)
157 changes: 157 additions & 0 deletions openless-all/app/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
@@ -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}"))?
}
Loading
Loading