From 3d4976913efea55b3e068a1651d2218ceceb9aa4 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 23 Jul 2026 10:42:55 +0800 Subject: [PATCH] feat(product-suite): decouple ai writing gateway --- app/ai.py | 4 - app/gui/tabs/product_suite.py | 53 +++++++++---- app/gui/tabs/settings.py | 4 +- app/gui/workers.py | 2 - docs/04-architecture.md | 4 +- docs/routes.md | 4 +- docs/tasks/T-695.md | 7 +- tests/test_ai.py | 2 + tests/test_product_suite_gui.py | 130 ++++++++++++++++++++++++++++++++ tests/test_workers.py | 2 +- 10 files changed, 183 insertions(+), 29 deletions(-) diff --git a/app/ai.py b/app/ai.py index 30e628e..88b607b 100644 --- a/app/ai.py +++ b/app/ai.py @@ -269,10 +269,6 @@ def analyze_product_images( """Use the dedicated cmhub vision route for product-suite AI writing.""" cfg = appconfig.load_config() if config is None else config - ai_cfg = appconfig.ai_config(cfg) - if _ai_backend(ai_cfg) != "cmhub": - raise AIError("商品套图AI帮写需要使用 cmhub 图片理解服务,请到设置配置 cmhub。") - paths = _validate_cmhub_vision_images(image_paths) runtime = _cmhub_runtime(cfg, "vision", cmhub_config_path) payload = { diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 1e886f6..190c9a1 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -4604,14 +4604,30 @@ class ProductSuiteTab(QWidget): return self._status("已完成已提交图片查询,请查看生成结果", "success") - def _require_default_gateway(self, action): - if self._is_default_gateway(): - return True - self._message("当前不可用", "%s仅支持默认网关,请到⑤设置切换后再使用。" % action) - return False + def _ai_write_config_error(self): + try: + cmhub_config = appconfig.cmhub_config(self.config) + base_url = appconfig.normalize_cmhub_base_url(cmhub_config.get("base_url")) + api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path) + alias = str(cmhub_config.get("vision_alias") or "").strip() + except Exception: + return "默认网关图片理解配置无法读取" + if not base_url: + return "尚未配置默认网关地址" + if not api_key: + return "尚未配置默认网关 API Key" + if not alias: + return "尚未选择图片理解模型" + return "" def start_ai_write(self, checked=False): - if not self._require_default_gateway("商品套图AI帮写"): + config_error = self._ai_write_config_error() + if config_error: + self._message( + "AI帮写配置不完整", + "%s。请到⑤设置补齐默认网关的地址、API Key 和图片理解模型。" + % config_error, + ) return if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"): return @@ -4634,10 +4650,14 @@ class ProductSuiteTab(QWidget): self._save_controls_to_state(state) selected_asset_ids = tuple(int(asset.id) for asset in selected_assets) catalog_params = self._ai_write_catalog_params() + if catalog_params is None: + self._message( + "AI帮写配置不完整", + "默认网关图片理解配置无法读取,请到⑤设置检查后重试。", + ) + return cached_models = ( cmhub_models.cached_model_catalog(catalog_params["base_url"], catalog_params["alias"]) - if catalog_params is not None - else None ) if cached_models is not None: self._confirm_ai_write_request( @@ -4646,9 +4666,6 @@ class ProductSuiteTab(QWidget): self._ai_write_points_cost(cached_models, catalog_params["alias"]), ) return - if catalog_params is None: - self._confirm_ai_write_request(state, selected_asset_ids, None) - return self._start_ai_write_catalog_lookup(state, selected_asset_ids, catalog_params) def _ai_write_selected_assets(self, state): @@ -5962,6 +5979,7 @@ class ProductSuiteTab(QWidget): pull_running = state.pull_running() default_gateway = self._is_default_gateway() supported_gateway = default_gateway or self._is_direct_gateway() + ai_write_config_error = self._ai_write_config_error() self.pull_button.setText( "正在停止..." if pull_running and state.pull_stop_requested @@ -6018,13 +6036,18 @@ class ProductSuiteTab(QWidget): self._refresh_totals(state) ai_running = state.ai_worker is not None or state.ai_price_worker is not None self.ai_write_button.setEnabled( - default_gateway and not ai_running and not generation_running + not ai_write_config_error and not ai_running and not generation_running ) - if not default_gateway and not ai_running: - self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关") - self.generate_button.setToolTip("自定义网关生成不计点数,实际费用以服务商为准") + if ai_write_config_error and not ai_running: + self.ai_write_button.setToolTip( + "%s,请到⑤设置配置默认网关的地址、API Key 和图片理解模型" + % ai_write_config_error + ) else: self.ai_write_button.setToolTip("") + if not default_gateway and not ai_running: + self.generate_button.setToolTip("自定义网关生成不计点数,实际费用以服务商为准") + else: self.generate_button.setToolTip("") self.ai_cancel_button.setVisible(ai_running) self._update_resume_submitted_action(state) diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index f318530..b8300b5 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -372,7 +372,9 @@ class SettingsTab(QWidget): model_detail_layout.addLayout(action_layout) model_detail_layout.addWidget(self.test_result_label) - self.direct_gateway_notice = QLabel("图片理解与商品套图仅支持默认网关。") + self.direct_gateway_notice = QLabel( + "商品套图使用自定义网关生图;AI帮写仍使用默认网关的图片理解配置。" + ) self.direct_gateway_notice.setObjectName("directGatewayNoticeLabel") self.direct_gateway_notice.setStyleSheet("color: #6b7280;") self.direct_role_panel = QWidget() diff --git a/app/gui/workers.py b/app/gui/workers.py index 5952724..113a068 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -525,8 +525,6 @@ class ProductSuiteAiWriteWorker(BaseWorker): def execute(self): if self.should_cancel(): return {"cancelled": True} - if appconfig.ai_backend(self.config) != "cmhub": - raise ValueError("商品套图AI帮写仅支持默认网关,请到⑤设置切换后再使用") result = ai.analyze_product_images( self.instruction, self.context, diff --git a/docs/04-architecture.md b/docs/04-architecture.md index bd4b85f..bb5634a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -456,10 +456,10 @@ data/images///__new. # AI 生成的新 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 - T-679a 后商品套图按 worker 启动时冻结的配置建立并持久化来源:默认网关 job 精确为 `cmhub/cmhub`,保留异步提交、轮询、下载、幂等键和点数账本;自定义网关 job 精确为 `direct/openai_images_edits`,走 T-678 的同步多图编辑、固定 `n=1`、无 `task_id`、无轮询和无自动重试。执行分派只读 job 持久化来源,不能受运行中设置变化影响;不匹配的来源/提供方或 direct job 带 task ID 均安全失败,避免跨来源串路。T-679b 已接通⑥新建入口与确认;T-679c 已补齐手动重试与混合来源历史展示,完整发布验收仍由 T-679 完成。 -- T-679b 后,⑥在自定义网关下仅本地校验当前已启用图片模型的接口类型、网址、模型 ID、密钥与连接超时;合格后可新建同步 direct job,但不读取 cmhub 模型目录、余额或价格。确认框按冻结 specs 展示数量、主参考图/参考图、逐图模式、请求尺寸和近似比例,并明确“自定义网关不计点数,实际费用以服务商为准”及主体一致性限制。AI帮写仍严格使用默认网关 `vision_alias`。生成期间当前任务输入锁定,任务切换、历史、日志和已完成图片查看不阻塞。 +- T-679b 后,⑥在自定义网关下仅本地校验当前已启用图片模型的接口类型、网址、模型 ID、密钥与连接超时;合格后可新建同步 direct job,但不读取 cmhub 模型目录、余额或价格。确认框按冻结 specs 展示数量、主参考图/参考图、逐图模式、请求尺寸和近似比例,并明确“自定义网关不计点数,实际费用以服务商为准”及主体一致性限制。AI帮写始终使用 cmhub `vision_alias`,其可用性只取决于 cmhub 图片理解配置,不受当前生图 backend 影响。生成期间当前任务输入锁定,任务切换、历史、日志和已完成图片查看不阻塞。 - direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` job;活动会话跳过,确认陈旧的 job 标为“程序中断,无法确认生成结果,请手动重新生成”。进入项目、切换任务或刷新结果不做该清理。T-679c 后「继续查询已提交图片」严格只处理 `cmhub/cmhub + task_id`,切换⑤设置不会改变该筛选;direct 永不进入此入口。结果卡、项目历史与全局历史均以 job 的持久化来源显示“默认网关 / 自定义网关”,不读取当前设置。direct 失败只允许用户手动“重新生成”:当前设置仍为自定义网关且配置合格时,必须重新展示完整生成确认,并额外警告服务商可能已经对上次未确认请求计费、本次可能再次收费;确认后始终新建 job,绝不复用旧 job 或自动补发。 - 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。 -- 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」仍仅使用默认网关 `vision_alias`。 +- 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」固定使用 cmhub `vision_alias`,即使套图生图选择自定义网关也不改走 direct。 - T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。 - T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。商品原图 UI 的第1张明确显示「主图」。内部排序由列表自行创建 `QDrag`,不得调用 `QAbstractItemView.startDrag(MoveAction)` 的源端清理;`dropEvent()` 按坐标以 `takeItem()` + `insertItem()` 唯一移动同一个项,不调用 Qt 默认 model drop,也不走“投放到图片上”的复制路径。`QDrag` 返回并清理拖拽上下文后才发送一次排序信号;结束时数量或 ID 集合异常则不写库,并从 SQLite 恢复界面。最终 ID 顺序唯一且确有变化时才由事务更新 `source_order`,排序只影响之后新建的一轮 job,不回写既有 job 快照。外部文件 URL 继续走导入。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。 - T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。 diff --git a/docs/routes.md b/docs/routes.md index 5196487..c8c0cb1 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -185,7 +185,7 @@ - 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。 - 自定义网关复用既有模型列表、其下紧凑显示的模型配置字段、默认文本模型与默认图像模型;不再显示重复的“模型详情”标题,模型选择区到配置表单保留 8px 间距。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②和⑥通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。 - 图片模型按钮显示「检查图片配置」,只在本地检查接口类型、URL、模型 ID、密钥和超时,绝不对 `/images/edits` 发送无图片的测试请求;实际可用性必须以②或后续⑥的正常生成确认判断。允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。 - - T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 模型目录、余额或价格。AI帮写仍禁用并说明仅默认网关可用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 + - T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 生图模型目录、余额或价格。AI帮写与生图来源解耦,只要 cmhub 地址、API Key 和图片理解别名完整,在自定义生图网关下仍可使用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 - T-679c 后,「继续查询已提交图片」只显示并处理 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的任务,切换到自定义网关后仍可继续查询旧默认网关任务;direct job 永不进入该入口。结果卡、项目历史和全局历史固定显示 job 已保存的“默认网关 / 自定义网关”,不因⑤设置切换改写;同轮混合来源显示“默认网关、自定义网关”。direct 失败只能手动“重新生成”,并再次显示完整生成确认及“服务商可能已对上次未确认请求计费,本次可能再次收费”的默认取消警告。确认后新建 job,不自动重试或复用旧 job;默认网关历史、导出、删除撤销和继续查询保持原有行为。 - T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可通过「新增 / 重命名 / 删除」维护并立即保存,切换回默认网关不会清除这些配置。重命名会保留模型的连接参数和本地 API Key,并同步迁移 `config.json` 中标题/图片默认模型对旧名称的引用;取消、空名称或重名不会写入。 @@ -229,7 +229,7 @@ - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。默认网关停止会取消未开始任务、保留已提交任务供继续查询;自定义网关停止同样只取消未开始 job,已发出的同步请求不可强杀,界面明确显示“正在停止,等待当前图片返回后结束”,已收到图片仍会保存成功。生成期间可切换其他任务、查看日志、历史和已完成图片;当前任务的账号、商品ID、原图、提示词、比例、分类和生成设置锁定,避免把运行时修改误认为会影响已冻结 job。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - T-648 后,常规「生成套图」在原图、卖点和数量校验通过后、费用确认前,若 SQLite 记录显示当前商品已有成功套图,会出现「已有套图生成记录」确认框:用户可查看仅当前商品的全局历史、继续生成新一轮或取消,默认取消;查看历史和取消都不提交任务,继续仍须通过原费用确认后才创建新轮次。失败图片重试、恢复未完成任务和仅失败/已取消历史不出现该确认。 - T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。T-649 将店铺筛选固定为首项「全部店铺」的下拉:当前账号显示「账号名(账号别名)」,已删除但仍有历史项目的账号显示「历史店铺:别名(账号已删除)」,选择值使用 `account_alias` 精确查询;商品 ID 关键字和“仅当前商品”可与其叠加,默认不限制当前任务。每一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。 -- AI帮写和生图按任务独立运行。AI帮写只使用默认网关设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。切换到自定义网关后,AI帮写禁用;新建套图可走同步直连图片编辑,已提交默认网关任务仍可继续查询、下载和保存。 +- AI帮写和生图按任务独立运行。AI帮写固定使用 cmhub 设置的「图片理解别名」调用图片理解能力,不走②标题生成,也不随套图生图 backend 切换到自定义网关;cmhub 地址、API Key 和图片理解别名完整时,在自定义生图网关下仍可使用。按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。自定义网关仍只用于新建同步直连套图,已提交默认网关任务继续查询、下载和保存。 - AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 - 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。 - 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 diff --git a/docs/tasks/T-695.md b/docs/tasks/T-695.md index f9be1ee..416f48a 100644 --- a/docs/tasks/T-695.md +++ b/docs/tasks/T-695.md @@ -3,7 +3,7 @@ id: T-695 title: 商品套图AI帮写与生图网关解耦 phase: 7 deps: [T-679b, T-694] -status: TODO +status: DONE created: 2026-07-23 --- @@ -70,4 +70,7 @@ created: 2026-07-23 ## 执行记录 -- 待实现。 +- 2026-07-23:完成商品套图生图网关与 AI帮写能力解耦。自定义网关继续只负责同步生图;AI帮写不再受全局 `ai.backend` 限制,始终独立校验并调用 cmhub Base URL、API Key 与 `vision_alias`。 +- GUI 按两套配置分别控制生成套图与 AI帮写按钮;缺少 cmhub 图片理解配置时只禁用 AI帮写并显示中文引导。Worker 和核心 AI 调用层同步移除 backend 拒绝,设置页说明及架构/路由文档已更新。 +- 新增并调整回归测试,覆盖 direct 生图下 AI帮写可用、两套配置错误互不影响、Worker 与核心图片理解仍只走 cmhub,以及既有前8张原图和临时草稿流程。 +- 验证通过:`py -3.10 -m unittest tests.test_product_suite_gui tests.test_workers tests.test_ai`(147 项)、`py -3.10 -m unittest discover -s tests`(681 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_ai.py b/tests/test_ai.py index 6ff5b29..fb7459c 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -541,6 +541,7 @@ class AITests(TempDirMixin, unittest.TestCase): def test_cmhub_analyze_product_images_uses_vision_alias_and_safe_metadata(self): with self.make_temp_dir() as temp_dir: cfg, key_path = self._cmhub_config(temp_dir) + cfg["ai"]["backend"] = "direct" first = os.path.join(temp_dir, "first.png") second = os.path.join(temp_dir, "second.jpg") with open(first, "wb") as fh: @@ -602,6 +603,7 @@ class AITests(TempDirMixin, unittest.TestCase): def test_cmhub_analyze_product_images_requires_vision_alias_and_local_limits(self): with self.make_temp_dir() as temp_dir: cfg, key_path = self._cmhub_config(temp_dir) + cfg["ai"]["backend"] = "direct" source = os.path.join(temp_dir, "source.jpg") with open(source, "wb") as fh: fh.write(b"source") diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index faf8f4b..9729ba0 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -127,6 +127,33 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): model["api_key"] = "test-image-key" appconfig.save_ai_models_config(models_config, path=models_path) + def _configure_cmhub_vision(self, config): + ai_config = appconfig.ai_config(config) + ai_config["cmhub"].update( + { + "base_url": "https://cmhub.example.com", + "vision_alias": "vision-standard", + } + ) + config["ai"] = ai_config + appconfig.save_cmhub_config( + {"api_key": "test-key"}, + path=config["cmhub_config_path"], + ) + cmhub_models.cache_model_catalog( + "https://cmhub.example.com", + [ + { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 2}], + } + ], + ) + self.addCleanup(cmhub_models.clear_model_catalog_cache) + def _create_history_job( self, project, @@ -209,6 +236,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(tab.generate_button.isEnabled()) self.assertFalse(tab.ai_write_button.isEnabled()) + self.assertIn("默认网关", tab.ai_write_button.toolTip()) self.assertFalse(tab.resume_submitted_button.isHidden()) self.assertTrue(tab.resume_submitted_button.isEnabled()) self.assertEqual( @@ -219,6 +247,106 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_direct_generation_gateway_keeps_ai_write_available_with_cmhub_vision(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = appconfig.ai_config(config) + config["ai"]["backend"] = "direct" + config["ai"]["cmhub"].update( + { + "base_url": "https://cmhub.example.com", + "vision_alias": "vision-standard", + } + ) + appconfig.save_cmhub_config( + {"api_key": "test-key"}, + path=config["cmhub_config_path"], + ) + self._configure_direct_image_gateway(config) + project, assets = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + self.addCleanup(cmhub_models.clear_model_catalog_cache) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + cmhub_models.cache_model_catalog( + "https://cmhub.example.com", + [ + { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 2}], + } + ], + ) + + self.assertTrue(tab.generate_button.isEnabled()) + self.assertTrue(tab.ai_write_button.isEnabled()) + self.assertEqual("", tab.ai_write_button.toolTip()) + with mock.patch.object(tab, "_confirm_ai_write_request") as confirm: + tab.start_ai_write() + + state_arg, asset_ids, points_cost = confirm.call_args.args + self.assertIs(state_arg, state) + self.assertEqual((assets[0].id,), asset_ids) + self.assertEqual("2", cmhub_models.format_points_cost(points_cost)) + + self.assert_removed(temp_dir) + + def test_direct_generation_and_ai_write_configuration_errors_are_independent(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = appconfig.ai_config(config) + config["ai"]["backend"] = "direct" + config["ai"]["cmhub"].update( + { + "base_url": "https://cmhub.example.com", + "vision_alias": "vision-standard", + } + ) + self._configure_direct_image_gateway(config) + project, _assets = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + + cases = ( + ("base_url", "", "尚未配置默认网关地址", "test-key"), + ("vision_alias", "", "尚未选择图片理解模型", "test-key"), + ("vision_alias", "vision-standard", "尚未配置默认网关 API Key", ""), + ) + for key, value, expected_error, api_key in cases: + with self.subTest(key=key, expected_error=expected_error): + tab.config["ai"]["cmhub"].update( + { + "base_url": "https://cmhub.example.com", + "vision_alias": "vision-standard", + key: value, + } + ) + appconfig.save_cmhub_config( + {"api_key": api_key}, + path=tab.cmhub_config_path, + ) + self.assertEqual(expected_error, tab._ai_write_config_error()) + tab._apply_running_state(state) + self.assertTrue(tab.generate_button.isEnabled()) + self.assertFalse(tab.ai_write_button.isEnabled()) + self.assertIn(expected_error, tab.ai_write_button.toolTip()) + + self.assert_removed(temp_dir) + def test_direct_gateway_preflight_and_confirmation_do_not_call_cmhub_catalog(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) @@ -1321,6 +1449,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): def test_ai_write_uses_first_eight_originals_in_source_order_regardless_of_checks(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) + self._configure_cmhub_vision(config) project, assets = self._create_project_with_assets(temp_dir, config, 9) tab = ProductSuiteTab(config=config, db_path=config["db_path"]) self.addCleanup(tab.close) @@ -3155,6 +3284,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): def test_temporary_draft_allows_local_work_but_blocks_shopee_pull_and_recovers(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) + self._configure_cmhub_vision(config) accounts.create_account("主店", "alias-a", debug_port=9222, config=config) tab = ProductSuiteTab(config=config, db_path=config["db_path"]) self.addCleanup(tab.close) diff --git a/tests/test_workers.py b/tests/test_workers.py index 7110a3c..c2d0b64 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -408,7 +408,7 @@ class WorkerTests(unittest.TestCase): "补充要求", "输出语言:繁体中文", image_paths=["first.jpg", "second.jpg"], - config={"ai": {"backend": "cmhub"}}, + config={"ai": {"backend": "direct"}}, cmhub_config_path="cmhub.json", ) expected = {