diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index aa470b5..216ed2a 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -1839,6 +1839,9 @@ class SuiteTaskState: ai_price_worker: object = None ai_price_thread: object = None ai_confirmation_open: bool = False + generation_price_worker: object = None + generation_price_thread: object = None + generation_confirmation_open: bool = False download_queue: list = field(default_factory=list) downloads: dict = field(default_factory=dict) download_tokens: dict = field(default_factory=dict) @@ -2358,14 +2361,18 @@ class ProductSuiteTab(QWidget): destructive=False, confirm_text="确认", cancel_text="取消", + default_cancel=False, ): box = QMessageBox(self) box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question) box.setWindowTitle(str(title)) box.setText(str(message)) confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole) - box.addButton(str(cancel_text), QMessageBox.RejectRole) - if not destructive: + cancel_button = box.addButton(str(cancel_text), QMessageBox.RejectRole) + if default_cancel: + box.setDefaultButton(cancel_button) + box.setEscapeButton(cancel_button) + elif not destructive: box.setDefaultButton(confirm_button) box.exec() return box.clickedButton() is confirm_button @@ -2524,7 +2531,11 @@ class ProductSuiteTab(QWidget): return state.generation_stop_requested = True state.worker.cancel() - for worker in (state.ai_worker, state.ai_price_worker): + for worker in ( + state.ai_worker, + state.ai_price_worker, + state.generation_price_worker, + ): if worker is not None: worker.cancel() if state.pull_running(): @@ -2589,6 +2600,12 @@ class ProductSuiteTab(QWidget): if self._loading: return if self._displayed_state is not None: + for worker in ( + self._displayed_state.ai_price_worker, + self._displayed_state.generation_price_worker, + ): + if worker is not None: + worker.cancel() self._save_controls_to_state(self._displayed_state) self._flush_prompt_save(self._displayed_state) state = self._state_for_index(index) @@ -4121,10 +4138,13 @@ class ProductSuiteTab(QWidget): ][: ai.CMHUB_VISION_MAX_IMAGES] def _ai_write_catalog_params(self): + return self._cmhub_catalog_params("vision_alias") + + def _cmhub_catalog_params(self, alias_key): try: cmhub_config = appconfig.cmhub_config(self.config) base_url = appconfig.normalize_cmhub_base_url(cmhub_config.get("base_url")) - alias = str(cmhub_config.get("vision_alias") or "").strip() + alias = str(cmhub_config.get(alias_key) or "").strip() api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path) except Exception: return None @@ -4356,6 +4376,13 @@ class ProductSuiteTab(QWidget): state = self._displayed_state if state is None: return + if state.generation_price_worker is not None: + state.generation_price_worker.cancel() + self._status("已请求取消读取套图预计扣点", "warning") + return + if state.generation_confirmation_open: + self._status("正在等待套图生成确认", "info") + return if state.generation_running(): retrying = state.generation_mode == "retry" if state.generation_stop_requested: @@ -4476,17 +4503,31 @@ class ProductSuiteTab(QWidget): for slot_index, spec in enumerate(specs): spec["generation_round_key"] = generation_round_key spec["generation_slot_index"] = slot_index - if confirm_batch and not self._confirm( - "确认生成商品套图", - self._generation_confirmation_message( + if confirm_batch: + return self._start_generation_price_confirmation( state, local_assets, specs, - ), - confirm_text="确认生成", - cancel_text="返回修改", - ): - return False + generation_round_key, + template_text, + ) + return self._start_generation_worker( + state, + specs, + generation_round_key, + retrying=retrying, + retry_job_id=retry_job_id, + ) + + def _start_generation_worker( + self, + state, + specs, + generation_round_key, + *, + retrying, + retry_job_id, + ): self._persist_state(state) run_token = uuid.uuid4().hex worker = ProductSuiteGenerateWorker( @@ -4540,7 +4581,213 @@ class ProductSuiteTab(QWidget): ) return True - def _generation_confirmation_message(self, state, local_assets, specs): + def _start_generation_price_confirmation( + self, + state, + local_assets, + specs, + generation_round_key, + template_text, + ): + if state.generation_price_worker is not None or state.generation_confirmation_open: + self._status("正在读取套图预计扣点", "info") + return False + snapshot = self._generation_plan_snapshot(state, local_assets, specs, template_text) + params = self._cmhub_catalog_params("image_alias") + cached_models = ( + cmhub_models.cached_model_catalog(params["base_url"], params["alias"]) + if params is not None + else None + ) + if cached_models is not None: + return self._confirm_generation_price_request( + state, + local_assets, + specs, + generation_round_key, + snapshot, + self._generation_points_estimate(cached_models, params["alias"], specs), + ) + if params is None: + return self._confirm_generation_price_request( + state, + local_assets, + specs, + generation_round_key, + snapshot, + None, + ) + worker = CMHubModelCatalogWorker( + params["base_url"], + params["api_key"], + connect_timeout=params["connect_timeout"], + use_system_proxy=params["use_system_proxy"], + ) + state.generation_price_worker = worker + worker.finished.connect( + lambda result, state=state, assets=list(local_assets), specs=list(specs), key=generation_round_key, plan=snapshot, alias=params["alias"]: + self._on_generation_catalog_finished( + state, + assets, + specs, + key, + plan, + alias, + result, + ) + ) + worker.cancelled.connect( + lambda result, state=state: self._on_generation_catalog_cancelled(state, result) + ) + state.generation_price_thread = self._start_thread(worker, "商品套图读取生图扣点") + if state is self._displayed_state: + self._apply_running_state(state) + self._status("正在读取套图预计扣点", "info") + return False + + @staticmethod + def _generation_points_estimate(models, alias, specs): + unit_cost = cmhub_models.unambiguous_points_cost( + models, + alias, + "image", + requires_image=True, + ) + if unit_cost is None: + return None + return (unit_cost, unit_cost * len(specs)) + + @staticmethod + def _generation_specs_signature(specs): + return tuple( + ( + int(spec.get("source_asset_id") or 0), + str(spec.get("category") or spec.get("job_type") or ""), + int(spec.get("category_index") or 0), + int(spec.get("source_index") or 0), + str(spec.get("prompt") or ""), + ) + for spec in specs + ) + + def _generation_plan_snapshot(self, state, local_assets, specs, template_text): + return { + "asset_ids": tuple(int(asset.id) for asset in local_assets), + "settings": product_suite.normalize_suite_settings(state.settings), + "prompt": str(state.prompt or ""), + "item_id": str(state.item_id or ""), + "template_text": str(template_text or ""), + "specs": self._generation_specs_signature(specs), + } + + def _generation_plan_is_current(self, state, snapshot): + if not self._is_open_suite_state(state) or state.generation_running(): + return False + try: + template_text = prompts.load_product_suite_prompt(self.product_suite_prompt_path) + except prompts.PromptError: + return False + local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] + current_specs = product_suite.build_job_specs( + local_assets, + state.prompt, + state.settings, + state.item_id or "未绑定商品", + template_text=template_text, + ) + return snapshot == self._generation_plan_snapshot( + state, + local_assets, + current_specs, + template_text, + ) + + def _on_generation_catalog_finished( + self, + state, + local_assets, + specs, + generation_round_key, + snapshot, + alias, + result, + ): + state.generation_price_worker = None + state.generation_price_thread = None + if state is not self._displayed_state or not self._is_open_suite_state(state): + return + estimate = None + if result.get("ok") is False: + self._status("暂时无法取得套图预计扣点,实际以网关返回为准", "warning") + elif result.get("cancelled"): + self._apply_running_state(state) + return + else: + estimate = self._generation_points_estimate(result.get("models") or [], alias, specs) + self._confirm_generation_price_request( + state, + local_assets, + specs, + generation_round_key, + snapshot, + estimate, + ) + if state is self._displayed_state: + self._apply_running_state(state) + + def _on_generation_catalog_cancelled(self, state, result): + state.generation_price_worker = None + state.generation_price_thread = None + if state is self._displayed_state and self._is_open_suite_state(state): + self._status("已取消读取套图预计扣点", "warning") + self._apply_running_state(state) + + def _confirm_generation_price_request( + self, + state, + local_assets, + specs, + generation_round_key, + snapshot, + estimate, + ): + if state is not self._displayed_state or not self._is_open_suite_state(state): + return False + if not self._generation_plan_is_current(state, snapshot): + self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning") + return False + state.generation_confirmation_open = True + try: + confirmed = self._confirm( + "确认生成商品套图", + self._generation_confirmation_message( + state, + local_assets, + specs, + estimate=estimate, + ), + confirm_text="确认生成", + cancel_text="返回修改", + default_cancel=True, + ) + finally: + state.generation_confirmation_open = False + if not confirmed: + if state is self._displayed_state: + self._apply_running_state(state) + return False + if not self._generation_plan_is_current(state, snapshot): + self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning") + return False + return self._start_generation_worker( + state, + specs, + generation_round_key, + retrying=False, + retry_job_id=None, + ) + + def _generation_confirmation_message(self, state, local_assets, specs, *, estimate=None): counts = {} for spec in specs: category = str( @@ -4556,26 +4803,41 @@ class ProductSuiteTab(QWidget): for name in ordered if counts.get(name, 0) > 0 ] - return ( - "店铺:%s\n" - "商品ID:%s\n" - "可用商品原图:%d张\n" - "每张上传图分别作为主图生成:%s\n" - "%s\n" - "图片比例:%s\n" - "生成总数:%d张\n" - "商品卖点:已填写\n\n" - "本次生成会消耗 cmhub 点数。" - % ( - self._account_context_label(state), - state.item_id or "未绑定商品", - len(local_assets), - "是" if state.settings.get("per_image_primary") else "否", - "\n".join(category_lines), - state.settings.get("ratio") or "1:1", - len(specs), - ) + per_image_primary = bool(state.settings.get("per_image_primary")) + lines = [ + "店铺:%s" % self._account_context_label(state), + "商品ID:%s" % (state.item_id or "未绑定商品"), + "可用商品原图:%d张" % len(local_assets), + "", + "逐图主图:%s" % ("已开启" if per_image_primary else "未开启"), + ( + "说明:白底图只使用第一张原图;场景图、卖点图和自定义分类会按每张原图分别生成。" + if per_image_primary + else "说明:所有分类都只使用第一张可用原图生成。" + ), + "", + ] + lines.extend(category_lines) + lines.extend( + [ + "图片比例:%s" % (state.settings.get("ratio") or "1:1"), + "本次生成总数:%d张" % len(specs), + "商品卖点:已填写", + "", + ] ) + if estimate is None: + lines.append("本次会消耗 cmhub 点数,暂时无法取得预计扣点,实际以网关返回为准。") + else: + unit_cost, total_cost = estimate + lines.extend( + [ + "预计单张扣点:%s 点" % cmhub_models.format_points_cost(unit_cost), + "预计总扣点:%s 点" % cmhub_models.format_points_cost(total_cost), + "实际扣点以 cmhub 返回为准。", + ] + ) + return "\n".join(lines) def _generation_signal_token(self, payload=None): token = str((payload or {}).get("run_token") or "") @@ -5066,6 +5328,7 @@ class ProductSuiteTab(QWidget): def _apply_running_state(self, state): generation_running = state.generation_running() + generation_price_pending = state.generation_price_worker is not None pull_running = state.pull_running() self.pull_button.setText( "正在停止..." @@ -5105,6 +5368,12 @@ class ProductSuiteTab(QWidget): "QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }" "QPushButton:hover { background: #a40e26; }" ) + elif generation_price_pending: + self.generate_button.setText("取消读取扣点") + self.generate_button.setStyleSheet( + "QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }" + "QPushButton:hover { background: #a40e26; }" + ) else: self.generate_button.setStyleSheet( "QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }" @@ -5423,6 +5692,7 @@ class ProductSuiteTab(QWidget): state.import_worker, state.ai_worker, state.ai_price_worker, + state.generation_price_worker, ): if worker is not None and hasattr(worker, "cancel"): worker.cancel() diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cc5df15..04f75d3 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -84,7 +84,7 @@ imported → collected → generated → applied - 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。 - AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。 - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 -- cmhub 模型目录与 AI帮写预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。 +- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 - 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;旧 AI工场模板目录 `data/prompts/image_studio/<名称>.txt` 仅保留兼容,⑥商品套图直接把卖点文本与结构化设置保存在项目表。 @@ -436,7 +436,7 @@ 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”。 -- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。 +- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias。T-651 后,常规新一轮生成先完成历史确认,再按最终 `build_job_specs()` 在后台读取或复用短期模型目录缓存,最后才显示数量和扣点确认;逐图主图开启时白底图仅使用第一张原图,其他分类按每张原图展开。只有当前生图别名存在唯一无条件 `points_cost` 时显示单张与总预估点数,总数严格按 planned job 数量计算;目录不可用或价格有条件时不显示数字。确认默认、Esc 或关闭均取消,计划在读取期间发生变化时不提交旧 specs;单图重试和恢复不进入该常规确认。预估不落库、不预扣,实际扣点仍以 job 的 cmhub 响应为准。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。 - T-647 后⑥「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用⑤设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。 - `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。允许占位符为 `{套图名称}`、`{补充描述}`、平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点及四个只读规则;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 - `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 diff --git a/docs/api.md b/docs/api.md index 646ecfe..56b0eb0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -322,7 +322,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li - `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。 - `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。⑥「AI帮写」单独调用 `POST /api/v1/analyze/images`,只使用 `ai.cmhub.vision_alias`,不得回退或混用生文/生图别名;同一商品项目的原图在一条请求内联合理解,用户勾选状态不参与选图。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。 - 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。 -- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉和⑥AI帮写付费前确认共用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 只在进程内按规整网关地址和别名短期缓存公开模型元数据,不缓存 API Key,也不写入配置、SQLite、日志或导出。AI帮写仅在当前 `vision_alias` 命中 `operation_type=vision`、`requires_image=true`、`pricing_status=priced` 且有唯一无条件 `points_cost` 时显示预计扣点;其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。 +- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉、⑥AI帮写和⑥正式套图生成的付费前确认共用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 只在进程内按规整网关地址和别名短期缓存公开模型元数据,不缓存 API Key,也不写入配置、SQLite、日志或导出。AI帮写仅在当前 `vision_alias` 命中 `operation_type=vision`、`requires_image=true`、`pricing_status=priced` 且有唯一无条件 `points_cost` 时显示预计扣点;正式套图同样只在当前 `image_alias` 命中唯一无条件的图片生图价格时显示单张和按最终 `specs` 计算的总价。其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 - direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`;cmhub 生文读取等待固定600秒,⑥图片理解读取等待固定120秒且一次读超时不重试,提示结果未确认;②批量生图异步 submit 读取等待36秒、poll 单次读取等待15秒、本地总预算900秒,图片下载读取等待900秒;`gen_cover()` 旧同步兼容路径仍用900秒读取等待。 - 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。 diff --git a/docs/routes.md b/docs/routes.md index 1dfe049..ce7a40c 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -210,6 +210,7 @@ - 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张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 +- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成,关闭时所有分类只用第一张。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 ## 流程导航 diff --git a/docs/tasks/T-651.md b/docs/tasks/T-651.md index bd571b4..b606393 100644 --- a/docs/tasks/T-651.md +++ b/docs/tasks/T-651.md @@ -3,7 +3,7 @@ id: T-651 title: 商品套图生成前的数量与预计扣点确认 phase: 7 deps: [T-650] -status: TODO +status: DONE created: 2026-07-17 --- @@ -95,3 +95,5 @@ git diff --check ## 执行记录 - 2026-07-17:根据商品套图生成前的数量、逐图主图和预计扣点确认需求创建任务。待实现。 +- 2026-07-17:开始复用 T-650 模型目录缓存实现常规套图生成的明细扣点确认。 +- 2026-07-17:已实现常规套图生成的后台价格读取和二次确认:历史确认后按最终 `specs` 展示逐图主图说明、分类实际数量、总张数、比例及可确定的单张/总预估点数。计划变化、取消读取、切换或关闭任务均不会创建生图 job;单图重试和恢复保持原路径。验证:`py -3.10 -m unittest discover -s tests`(577 项通过)、`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_product_suite_gui.py b/tests/test_product_suite_gui.py index 68064a5..d7d54a2 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -3076,12 +3076,14 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertIn("主店(alias-a)", confirmations[0][1]) self.assertIn("商品ID:51100639510", confirmations[0][1]) self.assertIn("可用商品原图:1张", confirmations[0][1]) - self.assertIn("每张上传图分别作为主图生成:是", confirmations[0][1]) + self.assertIn("逐图主图:已开启", confirmations[0][1]) + self.assertIn("白底图只使用第一张原图", confirmations[0][1]) self.assertIn("图片比例:1:1", confirmations[0][1]) - self.assertIn("生成总数:", confirmations[0][1]) - self.assertIn("消耗 cmhub 点数", confirmations[0][1]) + self.assertIn("本次生成总数:", confirmations[0][1]) + self.assertIn("暂时无法取得预计扣点", confirmations[0][1]) self.assertEqual("确认生成", confirmations[0][2]["confirm_text"]) self.assertEqual("返回修改", confirmations[0][2]["cancel_text"]) + self.assertTrue(confirmations[0][2]["default_cancel"]) failed_job = image_studio.create_job( project.id, @@ -3118,6 +3120,132 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generation_confirmation_uses_planned_specs_for_cached_image_price(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = { + "cmhub": { + "base_url": "https://cmhub.example.com", + "image_alias": "image-standard", + } + } + appconfig.save_cmhub_config({"api_key": "test-key"}, path=config["cmhub_config_path"]) + project, _assets = self._create_project_with_assets(temp_dir, config, 3) + 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 + state.prompt = "轻便耐用,适合日常使用" + state.settings["per_image_primary"] = True + tab._load_state(state) + cmhub_models.cache_model_catalog( + "https://cmhub.example.com", + [ + { + "alias": "image-standard", + "operation_type": "image", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 2}], + } + ], + ) + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) and False + + self.assertFalse(tab.start_generation(state)) + + self.assertEqual(1, len(confirmations)) + message = confirmations[0][1] + self.assertIn("逐图主图:已开启", message) + self.assertIn("白底图:1张", message) + self.assertIn("场景图:6张", message) + self.assertIn("卖点图:6张", message) + self.assertIn("本次生成总数:13张", message) + self.assertIn("预计单张扣点:2 点", message) + self.assertIn("预计总扣点:26 点", message) + self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"])) + + self.assert_removed(temp_dir) + + def test_generation_confirmation_explains_non_per_image_primary(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) + 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.settings["per_image_primary"] = False + specs = product_suite.build_job_specs( + assets, + "商品卖点", + state.settings, + state.item_id, + template_text=prompts.load_product_suite_prompt(tab.product_suite_prompt_path), + ) + + message = tab._generation_confirmation_message( + state, + assets, + specs, + ) + + self.assertIn("逐图主图:未开启", message) + self.assertIn("所有分类都只使用第一张可用原图生成", message) + self.assertIn("本次生成总数:5张", message) + + self.assert_removed(temp_dir) + + def test_generation_confirmation_rejects_plan_changed_while_reading_price(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + 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) + template_text = prompts.load_product_suite_prompt(tab.product_suite_prompt_path) + specs = product_suite.build_job_specs( + assets, + state.prompt, + state.settings, + state.item_id, + template_text=template_text, + ) + snapshot = tab._generation_plan_snapshot(state, assets, specs, template_text) + state.prompt = "已修改卖点" + + with mock.patch.object(tab, "_confirm") as confirm: + started = tab._confirm_generation_price_request( + state, + assets, + specs, + "round-key", + snapshot, + None, + ) + + self.assertFalse(started) + confirm.assert_not_called() + self.assertIsNone(state.worker) + + self.assert_removed(temp_dir) + def test_successful_history_requires_decision_before_new_generation(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)