151 lines
18 KiB
Markdown
151 lines
18 KiB
Markdown
---
|
||
id: T-677
|
||
title: ⑤生成网关来源切换(默认网关/自定义网关)
|
||
phase: 7
|
||
deps: [T-517, T-529]
|
||
status: TODO
|
||
created: 2026-07-20
|
||
---
|
||
|
||
# T-677 ⑤生成网关来源切换(默认网关/自定义网关)
|
||
|
||
## 问题 / 背景
|
||
|
||
T-529 把普通用户的 AI 后端收口为 cmhub:⑤ 隐藏「AI 后端」下拉,保存固定写 `ai.backend=cmhub`。当前生文、生图、图片理解全部只走 cmhub 托管并扣点。
|
||
|
||
现在需要给用户一条自定义(OpenAI 兼容)网关的逃生口,用于 cmhub 托管上游不稳或用户自备算力的场景。相关代码事实:
|
||
|
||
- `direct` 路径并未删除,当前按既有 OpenAI 兼容请求形式工作:`app/ai.py` 生文走 `chat/completions`,生图支持 `api_type` = `chat` / `images_edits` / `auto`(`app/appconfig.py:41`),`appconfig` 会自动补 endpoint;配置 schema 与校验在 `app/appconfig.py`(`list_ai_models` / 模型规范化)。
|
||
- ⑤ 的自定义模型 UI(模型列表、模型详情、direct 角色选择)也都还在,只是被 `_on_backend_changed()` 无条件 `setVisible(False)` 硬藏(`app/gui/tabs/settings.py:580-584`),`backend_combo` 同样常隐。
|
||
- 因此本任务的主要工作是**恢复并规范双面板切换**,不是新建自定义配置体系。
|
||
|
||
覆盖面不对称是本任务最大的风险点:默认网关有生文/生图/图片理解三个角色,外加商品套图;`direct` 只有生文、生图两条路径。当前正式第六 Tab 是「商品套图」,旧 `ImageStudioTab` 仅作内部兼容,不能只写“AI工场入口拦截”而遗漏真实可见入口。
|
||
|
||
- `app/ai.py` 的 `analyze_product_images()` 在非 cmhub 时直接抛错(⑥商品套图AI帮写);
|
||
- `app/image_studio_generation.py` 全文只有 cmhub 分支,其 `_runtime()` 直接取 `ai._cmhub_runtime()`,**完全不看 `ai.backend`**。
|
||
|
||
若只改 UI 不补联动,用户切到「自定义网关」后商品套图仍会用默认网关 Key 继续跑、继续扣点——用户以为切走了而费用照扣,属于资金口径错误,必须在本任务同版本内处理。
|
||
|
||
## 方案
|
||
|
||
### 1. ⑤ 网关分区改为来源选择
|
||
|
||
- `app/gui/tabs/settings.py` 第一段 `_section_title("cmhub 网关", "settingsAiModelSectionTitle")` 文案改为「生成网关」,**保留该 section title**(⑤ 是长页面,分区标题与「角色与生成参数」「蝦皮更新执行」「基础设施」并列承担导航作用,不可直接删掉换控件)。
|
||
- 在该分区标题所在位置增加**互斥选中态**的来源选择器:「默认网关」/「自定义网关」。
|
||
- 用两个 `setCheckable(True)` 的按钮加入同一 `QButtonGroup`(`setExclusive(True)`)实现分段选择器外观,或等价的两个 `QRadioButton`;
|
||
- **不得使用普通 `QPushButton`**:这是状态选择不是动作,普通按钮无法表达"当前选中项",也无法表达"点击即生效还是保存生效";
|
||
- 两个控件各自设置 `objectName`(如 `gatewayDefaultButton` / `gatewayCustomButton`)供 GUI 测试选择。
|
||
- 选择器只改 pending 状态并切换面板显隐,**落盘发生在「保存设置」时**;选择器需接入 `_connect_dirty_signals()`,切换触发未保存提示,与⑤其他控件行为一致。载入配置时在 `_dirty_tracking_suspended()` 内设置选中态。
|
||
|
||
### 2. 恢复双面板显隐
|
||
|
||
- `_on_backend_changed()`(`app/gui/tabs/settings.py:579-587`)去掉硬编码隐藏,改为按当前来源双向显隐:
|
||
- 默认网关 → 显示 `cmhub_panel`,隐藏 `model_picker_panel`、`model_detail_section_title`、`model_detail_panel`、`direct_role_panel`;
|
||
- 自定义网关 → 相反。
|
||
- 自定义侧复用现有面板与控件,不新建:模型列表、模型详情(名称/接口地址/模型ID/API Key/api_type/超时/启用)、`direct_role_panel` 的默认文本模型与默认图像模型选择、以及现有模型测试按钮(`AIModelTestWorker`)。
|
||
- 两套配置的**测试按钮与结果标签必须分开**,自定义侧不得复用 `cmhub_result_label`。
|
||
- 别名下拉(`cmhub_title_alias_combo` / `cmhub_image_alias_combo` / `cmhub_vision_alias_combo`)属于 `cmhub_panel`,随面板整体显隐即可。
|
||
|
||
### 3. 配置读写与迁移
|
||
|
||
- 沿用现有 `ai.backend` 字段与取值 `cmhub` / `direct`(`appconfig.AI_BACKENDS`、`app/ai.py` 的 `_ai_backend()` 校验保持不变),**不新增第三种取值、不新增并行的来源字段**。
|
||
- `_collect_settings_values()` 中 `backend = "cmhub"` 的硬编码(`app/gui/tabs/settings.py:751`)改为读选择器当前值。
|
||
- **两套配置并存、切换不清空**:`data/config/cmhub.json` 与 `data/config/ai_models.json` 互不影响,来回切换后原配置仍在。
|
||
- 存量迁移天然成立:老配置 `backend=direct` 载入后选中「自定义网关」,`backend=cmhub` 或缺失按现状选中「默认网关」。
|
||
- 沿用 v3.1 约定:**允许先保存不完整配置**,②生成时再提示"去⑤补齐",切换来源时不弹阻断式校验。
|
||
- 保存语义必须明确且不混淆:来源选择器仅修改待保存状态,只有点击「保存设置」才切换运行来源;既有「新增 / 保存 / 删除模型」仍立即写入 `ai_models.json`,不会等待「保存设置」。用户点击放弃设置时,只恢复来源和普通设置字段,不回滚已经单独保存的模型 CRUD。
|
||
- `api_type=auto` 不是自动探测接口:当前 direct 实现只有 `images_edits` 走图片编辑请求,`chat` 与 `auto` 都走对话请求。本任务不新增 `images/generations`、provider 适配或接口探测;自定义网关 UI 应将用户可见字段改为「接口类型」,并在帮助文案中按现有三种行为说明,不得泛称所有 OpenAI 兼容服务均可用。
|
||
|
||
### 3a. 运行配置冻结与保存竞争
|
||
|
||
- ② `GenerateWorker`、⑥商品套图生成与 AI 帮写开始时必须冻结本轮 AI **运行时快照**;运行中打开⑤并保存来源切换,不得让同一批任务前后混用默认网关和自定义网关。
|
||
- 运行时快照不能只是浅拷贝共享 `config`:默认网关的 API Key 在独立 `cmhub.json` 中读取,自定义模型和 API Key 在 `ai_models.json` 中读取。worker 创建前必须深拷贝本轮实际所需的来源、地址、别名/模型、超时、代理和密钥到**仅内存**的运行时对象;运行期间不得再次从上述配置文件读取这些值。密钥不得写入 SQLite、运行日志、异常正文或 UI。
|
||
- ⑤保存来源成功后,主窗口需通知⑥刷新来源能力状态;⑥再次显示时也应重新检查当前来源。可以用 `SettingsTab` 的保存成功 signal/callback 与 `MainWindow` 转发实现,但不得依赖用户重启程序。
|
||
- 即使 UI 刷新遗漏,worker/service 层仍必须按创建时的冻结运行时对象执行,不能读取被后续保存原地修改的共享 `config` 或配置文件。
|
||
|
||
### 4. 必须同版本处理的六个联动
|
||
|
||
1. **⑥商品套图入口拦截与已提交任务恢复**:`ai.backend != "cmhub"` 时,正式可见的 `ProductSuiteTab`「生成套图」、会新建请求的图片重试和「AI帮写」入口置灰并说明「商品套图仅支持默认网关」;保存来源切换后立即刷新,切入⑥时也再次刷新。
|
||
|
||
- 已提交的默认网关任务例外:若当前项目存在 `generation_source="cmhub"`、`provider="cmhub"` 且 `task_id` 非空、可恢复的 job,⑥仍须提供显式「继续查询已提交图片」入口(或在进入项目后自动恢复),供用户轮询、下载和保存已扣点任务;该入口不能伪装为「重试」,因为重试会新建并再次计费。
|
||
- 继续查询失败且默认网关配置已被删除或更换时,提示「默认网关配置不可用,请恢复原默认网关配置后继续查询已提交图片」,不得改走自定义网关、不得自动新建任务,也不得暴露密钥。
|
||
- 旧 `ImageStudioTab` 不在主 Tab 栏(`app/gui/widgets.py:57` 的 `TAB_TITLES` 不含它,`tests/test_gui.py` 显式断言其不存在),可复用其 worker/service,但**不能把它作为⑥用户恢复已扣点任务的唯一入口**。
|
||
2. **⑥防御性服务拦截,但必须区分"新建"与"取回"**:UI 置灰不足以作为费用边界,同时**不得把已经扣过点的任务困死**。
|
||
|
||
- **拒绝**:新建与新提交——`ProductSuiteGenerateWorker` 在直接调用 `image_studio.create_job()` 前拒绝非默认网关;`image_studio_generation.create_generation_jobs()` 必须新增并接收运行时来源参数后再建 job,避免其他调用方绕过;`_submit_or_resume_job()` 在**尚无商品套图 `task_id`** 的提交分支再次校验。三层判断使用同一守卫,不能只改其中一层。
|
||
- **放行**:已提交任务的轮询、下载与保存——`resume_image_jobs()` 同样走 `run_jobs()`(`app/image_studio_generation.py:142-163`),**不能在 `run_jobs()` 入口一刀切拒绝**,否则用户切到自定义网关后,此前已提交到默认网关、已预扣点数、正等待轮询取回的生图任务将永久拿不回来(钱已花、图丢失)。仅当 job 同时满足 `generation_source="cmhub"`、`provider="cmhub"`、`task_id` 非空且处于可恢复状态时,才允许继续用冻结的默认网关运行时轮询与下载;其他来源或无 `task_id` 的 job 必须拒绝,不能仅凭“存在任务 ID”放行。
|
||
- `task_id` 是 `image_studio_jobs` 的套图异步任务字段;普通② AI 生成任务的 `tasks.image_task_id` 是另一张表的字段,本任务涉及前者,文档、代码和测试不得混用。
|
||
- 口径依据:Obsidian《卡密设备绑定与套餐授权方案》第五节 §5「已被服务端接受的异步任务轮询、下载:允许,避免已预扣点数却拿不到结果」。本任务与该原则保持一致,实现时不要另立边界。
|
||
- `ProductSuiteAiWriteWorker` 与 `analyze_product_images()` 保持非默认网关不可用(图片理解无异步任务、不存在已扣点待取回的情形,可直接拒绝);UI 必须在读取默认网关模型目录、展示扣点确认、提交图片理解前拦截,而不是等待运行后报错。
|
||
3. **⑥商品套图说明**:⑤自定义面板增加说明行,明确「图片理解与商品套图仅默认网关支持」;拦截提示使用同一中文口径,不显示技术实现或 cmhub Key 信息。
|
||
4. **②自定义网关预检**:允许保存不完整配置,但点击②开始生成时、创建 `GenerateWorker` 前按本轮“只生成标题 / 只生成封面 / 生成图文”检查所需 direct 模型是否已启用、类别匹配、模型地址、模型 ID、API Key、接口类型齐全。失败时中文提示去⑤补齐,且不创建 worker、不发请求、不逐条改任务状态。不要等 `_role_model()` 在批量运行中逐条失败。
|
||
5. **「批量生成前检查余额」**(`cmhub_check_balance_checkbox`):自定义网关下隐藏——点数余额是默认网关概念。
|
||
6. **点数展示文案**:②自定义网关每轮开始时在运行日志和状态栏各明确一次显示「自定义网关(不计点数,费用由服务商收取)」,并清空/隐藏上轮默认网关余额;不得显示 `0 点`、空白余额或沿用旧余额。默认网关下 `points_cost` / `points_balance` 展示保持不变。
|
||
|
||
### 5. 用户可见文案收敛
|
||
|
||
⑤ 面板内以及⑤触发的状态栏用户可见文案中的 `cmhub` 字样收敛为「默认网关」口径,包括分区标题、Base URL/API Key placeholder 与帮助、刷新/测试按钮、连接中/成功/失败结果、明文保存警告(`PLAINTEXT_CMHUB_API_KEY_WARNING`,`app/gui/tabs/settings.py:17`)。
|
||
|
||
**②的余额展示一并纳入**:`cmhub_balance_label`(`app/gui/tabs/generate.py:1092`,默认文案「cmhub余额:未获取」)虽不在⑤内,但与⑤同屏出现在用户视野中。若只改⑤,会出现⑤显示「默认网关」而②显示「cmhub余额」的两套叫法,用户第一眼即困惑。该 label 的可见文案改为「默认网关余额」口径,与第 4 节第 6 条的自定义网关文案配套。
|
||
|
||
内部类名、`objectName`(含 `cmhub_balance_label` 本身的标识)、配置键、日志字段和 `app/ai.py` 的中文错误消息(如"连接 cmhub 超时")不改。
|
||
|
||
### 6. 效果图
|
||
|
||
按 `AGENTS.md` 约定出 `docs/ui/tab5-settings-T677.svg`(⑤ 是改版,已有 `tab5-settings.svg`),在 `docs/ui/README.md` 登记,随本任务文档一起提交。
|
||
|
||
## 验收要点
|
||
|
||
- [ ] ⑤ 生成网关分区显示互斥选中态的「默认网关」/「自定义网关」,当前选中项视觉可辨;两者均有 `objectName`。
|
||
- [ ] 切换来源立即切面板显隐并标记未保存;未点「保存设置」时不落盘,取消/重新载入后恢复原选择。
|
||
- [ ] 选默认网关时只显示默认网关配置(Base URL / API Key / 三个别名 / 连接超时 / 检查余额 / 测试连接·查余额);选自定义网关时只显示自定义模型列表、模型详情与默认文本/图像模型选择。
|
||
- [ ] 保存后 `config.json` 的 `ai.backend` 正确写入 `cmhub` / `direct`;`cmhub.json` 与 `ai_models.json` 在来回切换保存后内容均不丢失。
|
||
- [ ] 载入 `backend=direct` 的存量配置时选中「自定义网关」并正确回填自定义模型;缺失或 `cmhub` 时选中「默认网关」。
|
||
- [ ] 自定义网关下②生文、②生图可用;⑥商品套图的新建、重新生成和 AI 帮写入口被拦截并提示仅默认网关支持;worker/service 防线证明非默认网关不会创建 job、读取默认网关模型目录、提交新请求或扣点。
|
||
- [ ] **切到自定义网关后,⑥仍可在正式商品套图界面继续查询此前已提交的默认网关任务**:仅 `generation_source/provider` 均为 `cmhub` 且 `task_id` 非空的 job 可轮询、下载和保存,不因来源切换被拒绝或丢失;该入口不新建任务、不二次扣点。无 `task_id` 的 job、非默认网关来源 job、以及“重新生成”均被拒绝。
|
||
- [ ] 默认网关运行时配置缺失时,继续查询给出“恢复原默认网关配置后继续查询”的中文提示,不改走自定义网关,不暴露密钥。
|
||
- [ ] ②/⑥运行开始后保存来源切换、修改默认网关 Key 或修改自定义模型,不影响已启动任务的来源快照;快照深拷贝并包含独立配置文件读取的实际运行时值,密钥仅存在 worker 内存,保存完成后⑥按钮状态立即刷新,重新进入⑥时也正确刷新。
|
||
- [ ] 自定义网关下「批量生成前检查余额」不可见;②日志/状态栏显示"不计点数"口径而非 `0 点`,且清除旧余额;默认网关下点数与余额展示不回归。
|
||
- [ ] 自定义配置不完整时可保存;②按本轮生成内容在创建 worker 前给出"去⑤补齐"的可读提示,不发请求、不改任务状态、不崩溃。
|
||
- [ ] 自定义模型 CRUD 的立即保存与来源选择的待保存语义均有明确中文反馈;放弃设置不回滚已保存模型,但恢复未保存来源选择。
|
||
- [ ] `auto` 接口类型的实际请求行为有测试覆盖,不宣称自动探测或未实现的 `images/generations` 支持(若 `tests/test_ai.py` 现有用例已覆盖该分支,确认后即可,不必重复补)。
|
||
- [ ] ⑤ 面板、⑤触发的状态栏、以及②余额 label 的可见文案不再出现 `cmhub` 字样;内部标识(含 `objectName`、配置键、日志字段)和 `app/ai.py` 错误消息不在本次范围。
|
||
- [ ] `tests/test_gui.py` 覆盖:选择器互斥与默认选中、面板显隐、dirty 与保存生效、存量 `backend=direct` 载入、模型立即保存与来源待保存语义、②预检、⑥新建入口拦截与正式界面的已提交任务继续查询、余额控件显隐、点数文案分支、运行配置快照;`tests/test_image_studio_generation.py` 覆盖新建提交守卫、`task_id` 恢复守卫、`generation_source/provider` 不匹配拒绝及来源切换后的轮询下载;`tests/test_ai.py` 覆盖 direct 文本/图片、`auto` 的既有请求路径与缺配置报错;现有 backend 相关断言同步更新不回归。
|
||
- [ ] `docs/ui/tab5-settings-T677.svg` 已产出并在 `docs/ui/README.md` 登记。
|
||
|
||
## 测试与文档
|
||
|
||
- 更新 `docs/cmhub-integration-design.md`:补一条修订说明,记录 T-529 的"普通 UI 不提供后端切换"在本任务被有限放开,及自定义网关的定位与覆盖边界。
|
||
- 更新 `docs/routes.md` / `docs/api.md` 中⑤设置与生成来源的描述。
|
||
- 运行:
|
||
|
||
```bash
|
||
py -3.10 -m unittest discover -s tests -p "test_gui.py"
|
||
py -3.10 -m unittest discover -s tests
|
||
py -3.10 -m ruff check app tests main.py
|
||
py -3.10 -m compileall app main.py
|
||
git diff --check
|
||
```
|
||
|
||
## 边界(不改什么)
|
||
|
||
- 不给商品套图(`app/image_studio_generation.py`)和图片理解(`analyze_product_images()`)新增自定义网关路径;本任务只做入口拦截与提示。
|
||
- 不实现方案中经 cmhub 代理透传的正式 BYOK(T-596 / T-599~T-602 仍按其门禁推进);本任务的自定义网关是**客户端直连的过渡形态**,正式商业版需迁移到网关代理并重新收口 direct 入口。
|
||
- 不修改 `ai_models.json` 的 schema 与 `API_TYPES` 取值,不新增 provider 适配。
|
||
- 不改 `app/ai.py` 的 cmhub 请求协议、重试与超时策略、异步 submit/poll 状态机、幂等键。
|
||
- 不改 `app/ai.py` 中文错误消息里的 `cmhub` 字样。
|
||
- 不新增 `images/generations`、接口自动探测或 provider 专用适配;自定义网关仅覆盖现有 direct 文本/图片请求能力。
|
||
- 不改 SQLite schema、Chrome/CDP、Excel 回写、蝦皮更新流程。
|
||
- 不涉及卡密、设备绑定、订阅授权(见 Obsidian《卡密设备绑定与套餐授权方案》,另行拆任务)。
|
||
|
||
## 关联
|
||
|
||
- T-529:默认 cmhub 网关并隐藏 AI 后端选择(本任务有限放开其 UI 收口)。
|
||
- T-517:⑤设置分区(分区标题与布局约定)。
|
||
- T-596:AI工场自定义模型(BYOK)总设计与启动门禁(`status: BLOCKED`,正式形态,本任务不解除其门禁)。
|
||
- Obsidian《卡密设备绑定与套餐授权方案》第五节 §9、第六节 §5:正式发行版需移除普通用户可通过配置开启的 direct 直连路径。
|
||
|
||
## 执行记录
|
||
|
||
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
|