From 2e6b93d7e0def0b880c8b787452c1a23cf3b5375 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 18:37:27 +0800 Subject: [PATCH] feat(product-suite): enable direct generation flow --- app/gui/tabs/product_suite.py | 96 ++++++++++++++++++++++-- app/gui/workers.py | 6 ++ docs/04-architecture.md | 3 +- docs/routes.md | 12 +-- docs/tasks/T-679b.md | 8 +- docs/ui/README.md | 1 + docs/ui/tab6-suite-gateway-T679b.svg | 28 +++++++ tests/test_product_suite_gui.py | 105 ++++++++++++++++++++++++++- 8 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 docs/ui/tab6-suite-gateway-T679b.svg diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index a64a77b..d9e318d 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -69,6 +69,7 @@ from ... import ( cmhub_models, diagnostics, image_studio, + image_studio_generation, image_studio_images, product_suite, prompts, @@ -1865,6 +1866,7 @@ class SuiteTaskState: generation_mode: str = "batch" generation_retry_job_id: int = None generation_round_key: str = "" + generation_source: str = "" worker: object = None thread: object = None generation_run_token: str = "" @@ -3195,8 +3197,10 @@ class ProductSuiteTab(QWidget): def _update_context_actions(self, state): is_draft = self._is_draft_state(state) pull_running = bool(state and state.pull_running()) + generation_running = bool(state and state.generation_running()) self.pull_button.setEnabled( state is not None + and not generation_running and ( pull_running or is_draft @@ -3204,7 +3208,9 @@ class ProductSuiteTab(QWidget): ) ) self.pull_button.setToolTip( - "正在停止拉取" + "生成中不能拉取蝦皮主图" + if generation_running + else "正在停止拉取" if pull_running and state.pull_stop_requested else "停止拉取蝦皮主图" if pull_running @@ -4181,6 +4187,45 @@ class ProductSuiteTab(QWidget): except Exception: return False + def _is_direct_gateway(self): + try: + return appconfig.ai_backend(self.config) == "direct" + except Exception: + return False + + def _direct_generation_config_error(self): + """Return a user-safe direct image-model preflight error, if any.""" + + try: + ai_config = appconfig.ai_config(self.config) + model_name = str(ai_config.get("default_image_model") or "").strip() + if not model_name: + return "尚未选择图片模型" + model = appconfig.get_model( + model_name, + path=appconfig.ai_models_config_path(self.config), + ) + except Exception: + return "当前图片模型不存在或无法读取" + if not model.get("enabled", True): + return "当前图片模型未启用" + return appconfig.image_model_config_error(model) + + def _ensure_generation_gateway(self): + if self._is_default_gateway(): + return True + if self._is_direct_gateway(): + error = self._direct_generation_config_error() + if not error: + return True + self._message( + "自定义网关配置不完整", + "商品套图无法生成:%s。\n请到⑤设置补齐并保存图片模型配置。" % error, + ) + return False + self._message("生成网关不可用", "请到⑤设置选择默认网关或配置自定义网关。") + return False + @staticmethod def _is_default_gateway_job(job): return ( @@ -4578,6 +4623,8 @@ class ProductSuiteTab(QWidget): ( "确认停止当前图片重试吗?已提交任务会在安全边界停止。" if retrying + else "确认停止当前任务吗?正在请求的自定义网关图片无法立即撤销;如果图片已返回,程序会先保存,再结束后续任务。" + if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT else "确认取消当前任务吗?已提交任务会在安全边界停止。" ), destructive=True, @@ -4593,6 +4640,8 @@ class ProductSuiteTab(QWidget): self._status( "已请求停止当前图片重试" if retrying + else "正在停止,等待当前图片返回后结束" + if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT else "已请求停止当前套图任务", "warning", ) @@ -4600,7 +4649,7 @@ class ProductSuiteTab(QWidget): self.start_generation(state) def start_generation(self, state, specs=None, *, retry_job_id=None): - if not self._require_default_gateway("商品套图生成"): + if not self._ensure_generation_gateway(): return False if state.generation_running(): self._status("当前套图任务仍在生成", "warning") @@ -4711,6 +4760,7 @@ class ProductSuiteTab(QWidget): retry_job_id, ): self._persist_state(state) + source = image_studio_generation.generation_source_for_config(self.config) run_token = uuid.uuid4().hex worker = ProductSuiteGenerateWorker( state.project_id, @@ -4728,6 +4778,7 @@ class ProductSuiteTab(QWidget): state.generation_terminal_streak = 0 state.generation_job_ids = [] state.generation_mode = "retry" if retrying else "batch" + state.generation_source = source["generation_source"] state.generation_retry_job_id = retry_job_id state.generation_round_key = generation_round_key state.done = 0 @@ -4775,6 +4826,15 @@ class ProductSuiteTab(QWidget): self._status("正在读取套图预计扣点", "info") return False snapshot = self._generation_plan_snapshot(state, local_assets, specs, template_text) + if self._is_direct_gateway(): + return self._confirm_generation_price_request( + state, + local_assets, + specs, + generation_round_key, + snapshot, + None, + ) params = self._cmhub_catalog_params("image_alias") cached_models = ( cmhub_models.cached_model_catalog(params["base_url"], params["alias"]) @@ -4861,6 +4921,7 @@ class ProductSuiteTab(QWidget): "item_id": str(state.item_id or ""), "template_text": str(template_text or ""), "specs": self._generation_specs_signature(specs), + "generation_backend": appconfig.ai_backend(self.config), } def _generation_plan_is_current(self, state, snapshot): @@ -4996,6 +5057,10 @@ class ProductSuiteTab(QWidget): 0, len(local_assets) - 1 - reference_count, ) + output_spec = image_studio_generation.requested_output_spec( + state.settings.get("ratio") or "1:1" + ) + direct_gateway = self._is_direct_gateway() lines = [ "店铺:%s" % self._account_context_label(state), "商品ID:%s" % (state.item_id or "未绑定商品"), @@ -5030,7 +5095,25 @@ class ProductSuiteTab(QWidget): "", ] ) - if estimate is None: + lines.extend( + [ + "输出尺寸:%s%s" + % ( + output_spec["requested_output_size"], + "(接近比例生成)" if output_spec["approximate_ratio"] else "", + ), + "", + ] + ) + if direct_gateway: + lines.extend( + [ + "生成来源:自定义网关", + "自定义网关不计点数,实际费用以服务商为准。", + "首图作为主要商品参考;参考图效果取决于模型,商品主体一致性可能弱于默认网关。", + ] + ) + elif estimate is None: lines.append("本次会消耗 cmhub 点数,暂时无法取得预计扣点,实际以网关返回为准。") else: unit_cost, total_cost = estimate @@ -5395,6 +5478,7 @@ class ProductSuiteTab(QWidget): state.generation_job_ids = [] state.generation_retry_job_id = None state.generation_round_key = "" + state.generation_source = "" state.worker = None state.thread = None state.done = success + failed + cancelled @@ -5535,6 +5619,7 @@ class ProductSuiteTab(QWidget): generation_price_pending = state.generation_price_worker is not None pull_running = state.pull_running() default_gateway = self._is_default_gateway() + supported_gateway = default_gateway or self._is_direct_gateway() self.pull_button.setText( "正在停止..." if pull_running and state.pull_stop_requested @@ -5554,6 +5639,7 @@ class ProductSuiteTab(QWidget): self.ratio_combo, self.per_image_checkbox, self.prompt_edit, + self.prompt_settings_button, self.add_category_button, self.custom_category_edit, ): @@ -5561,7 +5647,7 @@ class ProductSuiteTab(QWidget): for row in self.category_rows.values(): row.set_controls_enabled(not generation_running) self.generate_button.setEnabled( - generation_running or generation_price_pending or default_gateway + generation_running or generation_price_pending or supported_gateway ) if generation_running: self.generate_button.setText( @@ -5593,7 +5679,7 @@ class ProductSuiteTab(QWidget): ) if not default_gateway and not ai_running: self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关") - self.generate_button.setToolTip("商品套图生成仅支持默认网关") + self.generate_button.setToolTip("自定义网关生成不计点数,实际费用以服务商为准") else: self.ai_write_button.setToolTip("") self.generate_button.setToolTip("") diff --git a/app/gui/workers.py b/app/gui/workers.py index 3265475..0ba7d80 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -385,6 +385,12 @@ class ProductSuiteGenerateWorker(BaseWorker): raise ValueError("商品套图生成任务不能为空") jobs = [] source = image_studio_generation.generation_source_for_config(self.config) + source_label = ( + "自定义网关" + if source["generation_source"] == image_studio.GENERATION_SOURCE_DIRECT + else "默认网关" + ) + self.log.emit("[商品套图] 使用%s开始生成%d张" % (source_label, total)) for spec in self.job_specs: jobs.append( image_studio.create_job( diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 19858df..490e2dc 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -452,7 +452,8 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 任一组件生成后 `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-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`。生成期间当前任务输入锁定,任务切换、历史、日志和已完成图片查看不阻塞。 - direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` 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`。 diff --git a/docs/routes.md b/docs/routes.md index 74fd778..e98027d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -173,9 +173,9 @@ - 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。 - 生成网关(T-677):用具备选中态的「默认网关 / 自定义网关」来源选择器替代历史「AI 后端」下拉。切换只改变待保存状态并立即切换面板,点击「保存设置」才写入 `ai.backend=cmhub/direct`;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。 - 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。 - - 自定义网关复用既有模型列表、模型详情、默认文本模型与默认图像模型。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行仍明确「图片理解与商品套图仅支持默认网关」。 + - 自定义网关复用既有模型列表、模型详情、默认文本模型与默认图像模型。文本模型保留 `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 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。 - - ⑥商品套图在自定义网关下禁用新建、重新生成和 AI帮写;但已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。若默认网关配置已不可用,提示恢复原默认网关配置。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 + - T-679b 后,⑥商品套图在自定义网关下先本地校验已启用的图片模型、接口类型、网址、模型 ID、密钥与连接超时;不合格时阻止创建 job 并引导到⑤补齐。合格后可创建同步直连 job,确认框显示数量、主图/参考图、逐图模式、比例映射与“自定义网关不计点数,实际费用以服务商为准”,并说明首图为主要商品参考、主体一致性可能弱于默认网关;不请求 cmhub 模型目录、余额或价格。AI帮写仍禁用并说明仅默认网关可用。已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 - T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可维护并立即保存,切换回默认网关不会清除这些配置。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。自定义模型角色选择只在自定义网关面板显示。 @@ -215,10 +215,10 @@ - 「每张上传图分别作为主图生成」右侧固定显示可换行 helper:“多款式或多SKU图请勾选;同一商品多角度图不勾选,其余图会作为参考图一同提交。”确认生成框必须说明当前主图/参考图模式和实际参考图数;未开启且上传超过8张时,明确仅提交第1张主图加前7张参考图,以及未参与本轮的剩余数量。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 平台、站点、语言和比例四个生成设置下拉仅在闭合时拦截自身滚轮改值,并把滚动交给 `suiteConfigScroll`;展开时滚轮只浏览候选列表,不静默提交当前值。鼠标点击、方向键、回车和 `Alt+↓` 保持原有显式选择语义;账号、历史筛选和设置页等其它下拉不受影响。 -- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 +- 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。默认网关停止会取消未开始任务、保留已提交任务供继续查询;自定义网关停止同样只取消未开始 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帮写只使用默认网关设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。切换到自定义网关后,AI帮写禁用;新建套图可走同步直连图片编辑,已提交默认网关任务仍可继续查询、下载和保存。 - AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 - 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。 - 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 @@ -251,12 +251,12 @@ | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 | | `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 | -| `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 | +| `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、默认网关异步或自定义网关同步生成、AI帮写、结果历史与删除撤销 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 | | `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | -| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget | +| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget | > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 diff --git a/docs/tasks/T-679b.md b/docs/tasks/T-679b.md index e35495b..a4c14e8 100644 --- a/docs/tasks/T-679b.md +++ b/docs/tasks/T-679b.md @@ -3,7 +3,7 @@ id: T-679b title: 商品套图自定义网关生成入口与确认交互 phase: 7 deps: [T-679a] -status: TODO +status: DONE created: 2026-07-20 --- @@ -61,4 +61,8 @@ git diff --check ## 执行记录 -(做完在这里写:变更文件、界面决策、验证命令及结果。) +- 自定义网关下的⑥生成入口已开放:创建前只本地校验当前图片模型是否已启用,以及接口类型、网址、模型 ID、密钥和连接超时;不合格时不创建 job,并引导到⑤设置补齐。 +- 直连确认不读取 cmhub 模型目录、余额或价格,明确显示生成数量、主图/参考图规则、逐图模式、比例映射与近似比例尺寸,以及“自定义网关不计点数,实际费用以服务商为准”和主体一致性限制。默认网关的价格确认路径不变。 +- 生成中锁定当前任务的账号、商品 ID、原图、提示词、提示词设置、比例、分类和拉取入口;其它任务、历史、日志与已完成图片仍可查看。直连停止显示“正在停止,等待当前图片返回后结束”,已返回图片由 T-679a 状态机保存成功。AI帮写仍仅默认网关可用。 +- 更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/ui/tab6-suite-gateway-T679b.svg` 与 `docs/ui/README.md`,并新增直连预检、无 cmhub 目录调用、确认文案和停止状态 GUI 测试。 +- 验证:`py -3.10 -m unittest discover -s tests -p test_product_suite_gui.py`(62 项)、`py -3.10 -m unittest tests.test_product_suite_gui tests.test_gui`(273 项)、`py -3.10 -m unittest discover -s tests -p test_workers.py`(16 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过;完整 `py -3.10 -m unittest discover -s tests` 通过(649 项)。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 6067374..b27aa6d 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -17,6 +17,7 @@ | [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 | | [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | 商品套图(T-622 已实现):从「虾皮圈电商图生成器」商品套餐交互迁移并替换主界面 AI工场入口;单一套图类型,多任务标签,商品原图,平台/国家/语言/比例,AI 帮写,白底/场景/卖点及自定义分类,结果历史、重试、删除撤销与生成进度 | | [tab6-suite-prompt-dialog-T637.svg](tab6-suite-prompt-dialog-T637.svg) | 商品套图(T-637 提案):标题行「AI帮写/取消」紧随 label、最右新增「提示词设置」;提示词设置弹窗——左编辑基础模板(行序对齐调研笔记组装顺序,套图名称+补充描述、插入变量菜单、占位符校验、只读规则占位符内联)、右只读实时最终预览(预览分类下拉)、保存/恢复默认共用原子写盘;第一版不做同类多张序号/差异化 | +| [tab6-suite-gateway-T679b.svg](tab6-suite-gateway-T679b.svg) | 商品套图(T-679b 已实现):自定义网关生成确认展示主图/参考图、近似比例尺寸、服务商费用与主体一致性边界;生成中锁定当前任务输入,停止等待已发出同步请求返回,同时仍可切换其它任务与查看历史/结果 | | [workbench-redesign-v1.html](workbench-redesign-v1.html) | 工作台视觉提案(HTML 可交互原型):以当前 6 Tab 信息架构为基础,统一「数据准备区→表格命令区→表格→状态反馈区→完成任务区」页面骨架;命令按作用域分区(导入/筛选/选择集/工作流完成),表格补齐当前行、选择集、三态全选与排序契约;范围确认、部分成功、取消中间态和破坏性确认按干扰层级分配到对话框、信息条与 Toast;覆盖浅色/深色/高对比度近似、键盘(Tab/方向键/F6/Ctrl+F/F5)与 Windows 断点。商品状态相关交互已对齐 T-662b/c/d、T-663~T-665、T-667 定稿(无状态列与状态下拉筛选,③无强制更新入口)。颜色 Token 取自 `app/gui/widgets.py`。使用模拟数据,不接生产接口;页脚「原型说明与验收边界」列出假设与不可仅靠 HTML 验收的项。 | 应用图标(非效果图,是随程序发布的资源):`app/assets/cmshopee-logo.svg` 是可编辑母版,`cmshopee.ico`(16–256 共 9 档)用于 exe 图标和 GUI 窗口左上角,`cmshopee-256.png` 为预览与非 Windows 回退。三者由 `py -3.10 scripts/gen_logo.py` 从同一份几何定义生成,改样式请改脚本后重新生成,不要单独手改某一个文件。 diff --git a/docs/ui/tab6-suite-gateway-T679b.svg b/docs/ui/tab6-suite-gateway-T679b.svg new file mode 100644 index 0000000..d683d79 --- /dev/null +++ b/docs/ui/tab6-suite-gateway-T679b.svg @@ -0,0 +1,28 @@ + + + + + + + 蝦皮圈優化助手自定义网关 · 已保存 + + ① 导入采集② AI生成③ 更新蝦皮账号管理设置 + 商品套图 + + 套图任务 1历史生成 + 账号主店(papa)⌄ + 商品ID51513423876拉取主图 + 商品原图已下载 4 张 · 生成中已锁定当前任务已冻结 + 主商品图参考图 1参考图 2参考图 3 + 生成设置比例3:4 ⌄逐图主图未开启商品卖点与要求当前任务生成中,提示词、分类和比例已冻结切换其他任务、查看历史和结果不受影响套图结构配置白底图 1张 场景图 2张 卖点图 2张正在停止...正在停止,等待当前图片返回后结束 + 生成结果成功 2 张 · 生成中自定义网关 · 不计点数 · 当前图片请求完成后会保存 + 正在请求等待中套图 2/5(46秒) + + + 确认生成商品套图 + 店铺:主店(papa)商品ID:51513423876逐图主图:未开启第1张为主图,另有3张参考图随每次请求一同提交。白底图:1张 场景图:2张 卖点图:2张图片比例:3:4 输出尺寸:1024x1536(接近比例生成)本次生成总数:5张 + 自定义网关不计点数,实际费用以服务商为准。首图作为主要商品参考;主体一致性可能弱于默认网关。 + 返回修改确认生成 + diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 15be956..60486fe 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -115,6 +115,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ) return project, assets + def _configure_direct_image_gateway(self, config): + models_path = appconfig.ai_models_config_path(config) + ai_config = appconfig.ai_config(config) + ai_config["backend"] = "direct" + config["ai"] = ai_config + config["ai_models_path"] = models_path + models_config = appconfig.default_ai_models_config() + for model in models_config["models"]: + if model["category"] == "image": + model["api_key"] = "test-image-key" + appconfig.save_ai_models_config(models_config, path=models_path) + def _create_history_job( self, project, @@ -153,7 +165,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): path=db_path, ) - def test_direct_gateway_blocks_new_suite_actions_but_keeps_default_job_resume_available(self): + def test_direct_gateway_enables_new_suite_actions_but_keeps_default_job_resume_available(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) config["ai"] = appconfig.ai_config(config) @@ -180,11 +192,98 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): tab._load_state(state) tab.refresh_gateway_state() - self.assertFalse(tab.generate_button.isEnabled()) + self.assertTrue(tab.generate_button.isEnabled()) self.assertFalse(tab.ai_write_button.isEnabled()) self.assertFalse(tab.resume_submitted_button.isHidden()) self.assertTrue(tab.resume_submitted_button.isEnabled()) - self.assertIn("仅支持默认网关", tab.generate_button.toolTip()) + self.assertIn("不计点数", tab.generate_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) + project, assets = self._create_project_with_assets(temp_dir, config, 2) + self._configure_direct_image_gateway(config) + 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 + state.prompt = "轻便耐用,适合日常使用" + state.settings["per_image_primary"] = False + state.settings["ratio"] = "3:4" + tab._load_state(state) + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) and False + + with mock.patch.object( + tab, + "_cmhub_catalog_params", + side_effect=AssertionError("自定义网关不应读取 cmhub 目录"), + ): + self.assertFalse(tab.start_generation(state)) + + self.assertEqual(1, len(confirmations)) + message = confirmations[0][1] + self.assertIn("生成来源:自定义网关", message) + self.assertIn("自定义网关不计点数", message) + self.assertIn("主体一致性可能弱于默认网关", message) + self.assertIn("输出尺寸:1024x1536(接近比例生成)", message) + self.assertNotIn("cmhub 点数", message) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + + self.assert_removed(temp_dir) + + def test_direct_gateway_invalid_image_model_blocks_before_confirmation(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = appconfig.ai_config(config) + config["ai"]["backend"] = "direct" + 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 + state.prompt = "轻便耐用,适合日常使用" + tab._load_state(state) + messages = [] + tab._message = lambda title, message, **kwargs: messages.append((title, message)) + + self.assertFalse(tab.start_generation(state)) + self.assertEqual("自定义网关配置不完整", messages[0][0]) + self.assertIn("请到⑤设置", messages[0][1]) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + + self.assert_removed(temp_dir) + + def test_direct_gateway_stop_waits_for_current_image_result(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = appconfig.ai_config(config) + config["ai"]["backend"] = "direct" + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.worker = mock.Mock() + state.generation_source = image_studio.GENERATION_SOURCE_DIRECT + statuses = [] + tab._confirm = lambda *args, **kwargs: True + tab._status = lambda message, level=None: statuses.append((message, level)) + + tab.toggle_generation() + + state.worker.cancel.assert_called_once() + self.assertTrue(state.generation_stop_requested) + self.assertEqual("正在停止...", tab.generate_button.text()) + self.assertEqual(("正在停止,等待当前图片返回后结束", "warning"), statuses[-1]) self.assert_removed(temp_dir)