diff --git a/app/ai.py b/app/ai.py index ce2077e..75768f7 100644 --- a/app/ai.py +++ b/app/ai.py @@ -47,6 +47,10 @@ class CMHubError(AIError): CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024 CMHUB_TITLE_READ_TIMEOUT_SECONDS = 600 +CMHUB_VISION_READ_TIMEOUT_SECONDS = 120 +CMHUB_VISION_MAX_IMAGES = 8 +CMHUB_VISION_MAX_IMAGE_BYTES = 10 * 1024 * 1024 +CMHUB_VISION_MAX_TOTAL_BYTES = 32 * 1024 * 1024 CMHUB_IMAGE_READ_TIMEOUT_SECONDS = 900 CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS = 36 CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS = 15 @@ -162,6 +166,55 @@ def gen_title( return text +def analyze_product_images( + instruction, + context, + image_paths, + *, + config=None, + cmhub_config_path=appconfig.CMHUB_CONFIG_PATH, + on_event=None, +): + """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 = { + "prompt": _compose_product_suite_vision_prompt(instruction, context), + "model": runtime["alias"], + "images": [{"image_base64": _image_data_url(path)} for path in paths], + "parameters": {"temperature": 0.2}, + } + try: + data = _cmhub_call_with_retry( + "POST", + appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/analyze/images"), + runtime["api_key"], + payload=payload, + connect_timeout=runtime["connect_timeout"], + read_timeout=CMHUB_VISION_READ_TIMEOUT_SECONDS, + attempts=1, + on_retry=None, + ) + except CMHubError as exc: + raise _vision_cmhub_error(exc) from exc + + _emit_cmhub_metadata(on_event, data, "vision_request") + text = _extract_text(data).strip() + if not text: + raise AIError("图片理解服务未返回可用卖点,请稍后重试") + return { + "text": text, + "image_count": len(paths), + "metadata": _cmhub_metadata(data), + } + + def gen_cover( cover_prompt, old_cover_path, @@ -1338,17 +1391,67 @@ def _download_and_save_cmhub_cover(request_result, on_step=None): return saved_path +def _validate_cmhub_vision_images(image_paths): + paths = [str(path or "").strip() for path in list(image_paths or [])] + if not paths: + raise AIError("请先添加至少一张可用商品原图") + if len(paths) > CMHUB_VISION_MAX_IMAGES: + raise AIError("图片理解最多支持%d张商品原图" % CMHUB_VISION_MAX_IMAGES) + + total_size = 0 + for index, path in enumerate(paths, 1): + if not path or not os.path.isfile(path): + raise AIError("第%d张商品原图尚未下载完成,请稍后重试" % index) + try: + size = os.path.getsize(path) + except OSError as exc: + raise AIError("第%d张商品原图无法读取,请稍后重试" % index) from exc + if size > CMHUB_VISION_MAX_IMAGE_BYTES: + raise AIError("第%d张商品原图超过10MiB,无法进行AI帮写" % index) + total_size += size + if total_size > CMHUB_VISION_MAX_TOTAL_BYTES: + raise AIError("商品原图总大小超过32MiB,无法进行AI帮写") + return paths + + +def _vision_cmhub_error(exc): + code = str(getattr(exc, "code", "") or "unknown") + if code == "read_timeout": + message = "等待图片理解结果超时,结果未确认,请先查看点数余额或稍后重试" + elif code in {"connect_timeout", "network_error"}: + message = "连接图片理解服务失败,结果未确认,请检查网络后稍后重试" + else: + message = _cmhub_user_message(code, "") + if not message or message == code: + message = "图片理解服务暂时不可用,请稍后重试" + return CMHubError( + code, + message, + status=getattr(exc, "status", None), + retryable=False, + retry_after=getattr(exc, "retry_after", None), + ) + + def _cmhub_runtime(config, operation, cmhub_config_path): hub = appconfig.cmhub_config(config) api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path) - alias_key = "title_alias" if operation == "title" else "image_alias" + operation_config = { + "title": ("title_alias", "生文别名"), + "image": ("image_alias", "生图别名"), + "vision": ("vision_alias", "图片理解别名"), + } + try: + alias_key, alias_label = operation_config[str(operation or "")] + except KeyError as exc: + raise AIError("cmhub 操作类型无效") from exc missing = [] if not hub.get("base_url"): missing.append("Base URL") if not api_key: missing.append("API Key") if not hub.get(alias_key): - missing.append("生文别名" if operation == "title" else "生图别名") + missing.append(alias_label) if missing: raise CMHubError( "cmhub_not_configured", @@ -1379,6 +1482,18 @@ def _compose_title_prompt(title_prompt, old_title): return "请只返回新标题,不要解释。" +def _compose_product_suite_vision_prompt(instruction, context): + return ( + "请分析当前电商商品原图,并根据图片可见信息生成可直接编辑的「商品卖点与要求」。" + "请使用任务指定的输出语言,只输出卖点与画面要求正文,不要解释分析过程。" + "内容应包含商品名称或品类、颜色、款式、可见细节、核心卖点、目标人群、使用场景" + "和适合套图生成的画面要求。已有要求仅是补充约束,不能当作图片事实。" + "不要虚构材质、尺寸、功能、认证、价格、物流承诺或图片中不可确认的信息。" + "\n\n任务要求:\n%s\n\n任务上下文:\n%s" + % (str(instruction or "").strip(), str(context or "").strip()) + ) + + def _normalize_cmhub_resolution(resolution): value = str(resolution or "1k").strip().lower() mapping = { @@ -1697,14 +1812,20 @@ def _notify_cmhub_retry(callback, step, attempt, attempts, exc): pass -def _emit_cmhub_metadata(callback, data, step): - if callback is None or not isinstance(data, dict): - return - metadata = { +def _cmhub_metadata(data): + if not isinstance(data, dict): + return {} + return { key: data.get(key) for key in ("alias", "model_used", "points_cost", "points_balance", "call_id") if data.get(key) is not None } + + +def _emit_cmhub_metadata(callback, data, step): + if callback is None: + return + metadata = _cmhub_metadata(data) if not metadata: return try: diff --git a/app/appconfig.py b/app/appconfig.py index 4e96138..764c674 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -88,6 +88,7 @@ DEFAULT_CONFIG = { "base_url": "", "title_alias": "", "image_alias": "", + "vision_alias": "vision-standard", "connect_timeout": CMHUB_CONNECT_TIMEOUT_DEFAULT, "use_system_proxy": False, "download_with_curl": "auto", @@ -406,6 +407,9 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False): cmhub = ai.get("cmhub") if isinstance(cmhub, dict): cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", "")) + cmhub["title_alias"] = str(cmhub.get("title_alias", "") or "").strip() + cmhub["image_alias"] = str(cmhub.get("image_alias", "") or "").strip() + cmhub["vision_alias"] = str(cmhub.get("vision_alias", "") or "").strip() cmhub["connect_timeout"] = _normalize_cmhub_connect_timeout( cmhub.get("connect_timeout"), migrate_old_default=migrate_old_cmhub_connect_timeout, @@ -807,6 +811,7 @@ def cmhub_config(config=None) -> dict: merged["base_url"] = normalize_cmhub_base_url(merged.get("base_url", "")) merged["title_alias"] = str(merged.get("title_alias", "") or "").strip() merged["image_alias"] = str(merged.get("image_alias", "") or "").strip() + merged["vision_alias"] = str(merged.get("vision_alias", "") or "").strip() merged["connect_timeout"] = _normalize_cmhub_connect_timeout( merged.get("connect_timeout"), migrate_old_default=False, diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index f6bdac5..fcee533 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -54,6 +54,7 @@ from PySide6.QtWidgets import ( from ... import ( accounts, + ai, appconfig, diagnostics, image_studio, @@ -3386,8 +3387,9 @@ class ProductSuiteTab(QWidget): return local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] if not local_assets: - self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。") + self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。") return + selected_assets = local_assets[: ai.CMHUB_VISION_MAX_IMAGES] self._save_controls_to_state(state) context = ( "商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s" @@ -3400,15 +3402,15 @@ class ProductSuiteTab(QWidget): ) ) instruction = ( - "请为电商商品图片生成简洁、可执行的商品卖点与画面要求。" - "只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素," - "不要虚构商品参数。" + "请为当前商品原图生成简洁、可执行的商品卖点与画面要求。" + "内容应包含核心卖点、目标人群、使用场景和禁用元素,不要虚构商品参数。" ) state.ai_prompt_snapshot = state.prompt state.ai_started_at = time.monotonic() worker = ProductSuiteAiWriteWorker( instruction, context, + image_paths=[asset.local_path for asset in selected_assets], config=self.config, cmhub_config_path=self.cmhub_config_path, ) @@ -3419,7 +3421,10 @@ class ProductSuiteTab(QWidget): state.ai_thread = self._start_thread(worker, "商品套图AI帮写") if state is self._displayed_state: self._apply_running_state(state) - self._status("AI帮写已开始,可切换到其他套图任务", "info") + message = "AI帮写已开始,可切换到其他套图任务" + if len(local_assets) > ai.CMHUB_VISION_MAX_IMAGES: + message += ";已使用前%d张商品原图进行理解" % ai.CMHUB_VISION_MAX_IMAGES + self._status(message, "info") def cancel_ai_write(self, checked=False): state = self._displayed_state @@ -3444,7 +3449,11 @@ class ProductSuiteTab(QWidget): if state is self._displayed_state: self._offer_pending_ai_result(state) else: - self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success") + self._status( + self._ai_write_success_message(result) + + ",切回对应套图任务后确认是否采用", + "success", + ) else: state.prompt = text self._persist_state(state) @@ -3454,10 +3463,22 @@ class ProductSuiteTab(QWidget): self.prompt_edit.setPlainText(text) finally: self._loading = False - self._status("AI帮写已完成", "success") + self._status(self._ai_write_success_message(result), "success") if state is self._displayed_state: self._apply_running_state(state) + def _ai_write_success_message(self, result): + image_count = max(0, int(dict(result or {}).get("image_count", 0) or 0)) + parts = ["AI帮写完成"] + if image_count: + parts.append("已理解%d张商品原图" % image_count) + metadata = dict(dict(result or {}).get("metadata") or {}) + if metadata.get("points_cost") is not None: + parts.append("图片理解扣点%s" % metadata["points_cost"]) + if metadata.get("points_balance") is not None: + parts.append("当前余额%s" % metadata["points_balance"]) + return ":".join(parts[:2]) + ("," + ",".join(parts[2:]) if len(parts) > 2 else "") + def _offer_pending_ai_result(self, state): if not state.pending_ai_text: return diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 74839e8..6dd1ff6 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -88,6 +88,8 @@ class SettingsTab(QWidget): self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo") self.cmhub_image_alias_combo = QComboBox() self.cmhub_image_alias_combo.setObjectName("cmhubImageAliasCombo") + self.cmhub_vision_alias_combo = QComboBox() + self.cmhub_vision_alias_combo.setObjectName("cmhubVisionAliasCombo") self.cmhub_connect_timeout_spin = QSpinBox() self.cmhub_connect_timeout_spin.setObjectName("cmhubConnectTimeoutSpin") self.cmhub_connect_timeout_spin.setRange(1, 3600) @@ -304,6 +306,7 @@ class SettingsTab(QWidget): ("连接超时(秒)", self.cmhub_connect_timeout_spin), ("生文别名", self.cmhub_title_alias_combo), ("生图别名", self.cmhub_image_alias_combo), + ("图片理解别名", self.cmhub_vision_alias_combo), ("", self.cmhub_check_balance_checkbox), ] ) @@ -480,6 +483,7 @@ class SettingsTab(QWidget): self.backend_combo, self.cmhub_title_alias_combo, self.cmhub_image_alias_combo, + self.cmhub_vision_alias_combo, self.category_combo, self.api_type_combo, self.default_text_model_combo, @@ -814,6 +818,7 @@ class SettingsTab(QWidget): "base_url": appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text()), "title_alias": self.cmhub_title_alias_combo.currentData() or "", "image_alias": self.cmhub_image_alias_combo.currentData() or "", + "vision_alias": self.cmhub_vision_alias_combo.currentData() or "", "connect_timeout": self.cmhub_connect_timeout_spin.value(), "check_balance_before_batch": self.cmhub_check_balance_checkbox.isChecked(), } @@ -866,6 +871,7 @@ class SettingsTab(QWidget): self.cmhub_models, title_selected=cmhub_cfg.get("title_alias", ""), image_selected=cmhub_cfg.get("image_alias", ""), + vision_selected=cmhub_cfg.get("vision_alias", ""), ) self._set_combo_by_data( self.default_text_model_combo, @@ -1127,6 +1133,7 @@ class SettingsTab(QWidget): self.cmhub_api_key_edit, self.cmhub_title_alias_combo, self.cmhub_image_alias_combo, + self.cmhub_vision_alias_combo, self.cmhub_connect_timeout_spin, self.cmhub_check_balance_checkbox, self.cmhub_refresh_button, @@ -1149,17 +1156,27 @@ class SettingsTab(QWidget): current_cfg = appconfig.cmhub_config(self.config) title_selected = self.cmhub_title_alias_combo.currentData() or current_cfg.get("title_alias", "") image_selected = self.cmhub_image_alias_combo.currentData() or current_cfg.get("image_alias", "") + vision_selected = self.cmhub_vision_alias_combo.currentData() or current_cfg.get("vision_alias", "") self._populate_cmhub_alias_combos( models, title_selected=title_selected, image_selected=image_selected, + vision_selected=vision_selected, ) title_count = self._cmhub_alias_count("title") image_count = self._cmhub_alias_count("image") + vision_aliases = { + str(model.get("alias") or "").strip() + for model in self._cmhub_priced_models(models, "vision") + } + vision_count = len(vision_aliases) balance = payload.get("points_balance") balance_text = f";余额 {balance}" if balance is not None else "" subject = self._cmhub_success_subject(payload) - message = f"{subject}:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text};别名已拉取,记得点『保存设置』持久化" + vision_text = f"图片理解别名 {vision_count} 个" + if vision_selected and vision_selected not in vision_aliases: + vision_text += "(当前已保存值暂不可用)" + message = f"{subject}:生文别名 {title_count} 个,生图别名 {image_count} 个,{vision_text}{balance_text};别名已拉取,记得点『保存设置』持久化" self.cmhub_result_label.setText(message) self._set_status(message) @@ -1224,10 +1241,21 @@ class SettingsTab(QWidget): self.cmhub_result_label.setText(message) self._set_status(message) - def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""): + def _populate_cmhub_alias_combos( + self, + models, + title_selected="", + image_selected="", + vision_selected="", + ): if self._suspend_dirty <= 0: with self._dirty_tracking_suspended(): - self._populate_cmhub_alias_combos(models, title_selected, image_selected) + self._populate_cmhub_alias_combos( + models, + title_selected, + image_selected, + vision_selected, + ) return self._populate_cmhub_alias_combo( self.cmhub_title_alias_combo, @@ -1241,6 +1269,12 @@ class SettingsTab(QWidget): "image", image_selected, ) + self._populate_cmhub_alias_combo( + self.cmhub_vision_alias_combo, + models, + "vision", + vision_selected, + ) def _populate_cmhub_alias_combo(self, combo, models, operation, selected): combo.blockSignals(True) @@ -1270,12 +1304,28 @@ class SettingsTab(QWidget): alias = str(model.get("alias") or "").strip() op = str(model.get("operation_type") or "").lower() pricing_status = str(model.get("pricing_status") or "").lower() - if alias and op == operation and pricing_status != "unpriced": + requires_image = bool(model.get("requires_image")) + if ( + alias + and op == operation + and pricing_status != "unpriced" + and ( + operation != "vision" + or (pricing_status == "priced" and requires_image) + ) + ): items.append(model) return items def _cmhub_alias_count(self, operation): - combo = self.cmhub_title_alias_combo if operation == "title" else self.cmhub_image_alias_combo + combos = { + "title": self.cmhub_title_alias_combo, + "image": self.cmhub_image_alias_combo, + "vision": self.cmhub_vision_alias_combo, + } + combo = combos.get(operation) + if combo is None: + return 0 return sum(1 for index in range(combo.count()) if combo.itemData(index)) def _show_error(self, error): diff --git a/app/gui/workers.py b/app/gui/workers.py index 4af53a9..d62631e 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -419,34 +419,41 @@ class ProductSuiteGenerateWorker(BaseWorker): class ProductSuiteAiWriteWorker(BaseWorker): - """Generate product selling-point copy without blocking the suite workspace.""" + """Analyze local product images without blocking the suite workspace.""" def __init__( self, instruction, context, *, + image_paths=None, config=None, cmhub_config_path=None, ): super().__init__() self.instruction = str(instruction or "") self.context = str(context or "") + self.image_paths = [str(path or "") for path in list(image_paths or [])] self.config = config self.cmhub_config_path = cmhub_config_path def execute(self): if self.should_cancel(): return {"cancelled": True} - text = ai.gen_title( + result = ai.analyze_product_images( self.instruction, self.context, + self.image_paths, config=self.config, cmhub_config_path=self.cmhub_config_path, ) if self.should_cancel(): return {"cancelled": True} - return {"text": str(text or "").strip()} + return { + "text": str(result.get("text") or "").strip(), + "image_count": int(result.get("image_count", 0) or 0), + "metadata": dict(result.get("metadata") or {}), + } class ProductSuiteImportImagesWorker(BaseWorker): @@ -2875,10 +2882,12 @@ class CMHubSettingsWorker(BaseWorker): } title_count = self._priced_count(models, "title") image_count = self._priced_count(models, "image") + vision_count = self._priced_count(models, "vision") self._log_run_event( - "step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format( + "step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} vision_aliases={vision_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format( title_count=title_count, image_count=image_count, + vision_count=vision_count, points_balance=payload.get("points_balance") if payload.get("points_balance") is not None else "", elapsed_ms=elapsed_ms, ) @@ -2900,6 +2909,13 @@ class CMHubSettingsWorker(BaseWorker): if str(model.get("operation_type") or "").lower() == operation and str(model.get("pricing_status") or "").lower() != "unpriced" and str(model.get("alias") or "").strip() + and ( + operation != "vision" + or ( + str(model.get("pricing_status") or "").lower() == "priced" + and bool(model.get("requires_image")) + ) + ) ) def _create_run_log(self): diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cc70915..781f876 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -24,7 +24,7 @@ GUI(PySide6 QTabWidget,当前显示 6 Tab) ├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式 ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) ├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 - ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) + ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(⑥AI帮写) ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选) ├── product_suite 套图设置归一化、数量计算、完整提示词与job规划 ├── image_studio_images 远程原图安全下载、本地原图导入、生成图废纸篓 @@ -76,7 +76,7 @@ imported → collected → generated → applied - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 -- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;默认走 `cmhub` 网关,保留 `direct` 模型清单作为内部兼容/回滚 backend;返回值保持标题字符串/本地 JPEG 路径)。 +- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供⑥「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。 **存储(同一事实只存一处)** @@ -119,6 +119,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 "base_url": "", "title_alias": "", "image_alias": "", + "vision_alias": "vision-standard", "connect_timeout": 66, "check_balance_before_batch": false }, @@ -151,7 +152,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 `ai` 段只放**选择 + 全局生成参数**: - `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。 -- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页展示托管档位、别名和扣点提示;⑥商品套图直接使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。 +- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` / `vision_alias` 分别对应②标题、②/⑥正式生图、⑥「AI帮写」图片理解,`vision_alias` 缺失时旧配置迁移为 `vision-standard`,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页展示三类别名和扣点提示,其中图片理解下拉只接受 `operation_type=vision`、`requires_image=true` 且已定价的模型;已保存但暂不可用的值保留并明确提示。⑥正式套图仍只使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。 - `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。 - `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。 - `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。 @@ -434,6 +435,7 @@ 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”。 - ⑥商品套图固定使用⑤保存的 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` 快照。 +- T-645 后⑥「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 发送;超过8张时明确只使用前8张。单图上限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`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 diff --git a/docs/api.md b/docs/api.md index 0aaeb0b..62889cb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,7 +34,7 @@ ai_config(config=None) -> dict # default_text_model/default_image # resolution/resolution_timeouts product_suite_last_settings(config=None) -> dict # 最近的平台/站点/语言/比例;非法值回退默认 ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚 -cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout/download_with_curl +cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/vision_alias/connect_timeout/download_with_curl normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port) cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /api/v1/... response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率) @@ -60,7 +60,7 @@ save_cmhub_config(config, path="data/config/cmhub.json") -> dict get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str ``` -`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 +`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/vision_alias/connect_timeout` 等非密钥配置;`vision_alias` 默认 `vision-standard`,供⑥「AI帮写」独立使用。T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): ```python @@ -304,6 +304,9 @@ gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=N # 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits;cmhub 单独调用走旧同步 /generate/image 兼容路径; # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir///__new.jpg`,历史 DB 已存路径继续按原路径读取 +analyze_product_images(instruction, context, image_paths, config=None, cmhub_config_path="data/config/cmhub.json", on_event=None) -> dict +# 仅⑥商品套图AI帮写使用;backend 必须为 cmhub。按输入顺序校验1..8张本地图片(单张<=10MiB、合计<=32MiB),POST /api/v1/analyze/images,返回 {text, image_count, metadata};metadata 只允许 alias/model_used/points_cost/points_balance/call_id。 + generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict] # 编排:按 ai.generate_mode 跑标题和/或封面;title/title_cover 用 title_concurrency,cover/title_cover 用 image_concurrency @@ -317,11 +320,11 @@ 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`。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。 +- `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` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 可读取 `display_name/tags/recommended_for/tier/prices` 生成“默认档 / 高质量档 / 省点档”中文说明,但执行层只保存 cmhub alias。 - `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 生文读取等待固定 600s。②批量生图异步 submit 读取等待 36s、poll 单次读取等待 15s、本地总预算 900s,图片下载读取等待 900s;`gen_cover()` 旧同步兼容路径仍用 900s 读取等待。 +- 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。 - 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。cmhub 批量生图使用内部实际并发 `min(image_concurrency, 5)` 约束 submit+poll 在途数;已有 `tasks.image_task_id` 时直接 GET 续查,不二次 submit;submit 前先持久化 `image_task_key`,submit 成功立即持久化 `image_task_id`;poll `failed/expired` 会清空二者,poll 超时/用户停止/程序退出则保留二者供下次续查。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数与实际生图并发一致且最大 5;T-548 后图片下载后端由 `ai.cmhub.download_with_curl` 控制,默认 `auto`:Windows 且检测到系统 curl 时优先用 curl 下载,否则回退 requests;curl 失败也会自动回退 requests。下载前仍执行公网 URL 校验,curl 使用 `-K` 临时配置文件传入 URL,不把带 token 的 `image_url` 放进命令行参数;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。下载失败最多安全重试 3 次,只复用同一个 `image_url`,不会重新调用 cmhub 生图接口;下载总耗时超过 20 秒时写“图片下载较慢”警告;direct 兼容路径暂保持原 `image_concurrency` 语义。 - cmhub 图片地址兼容:优先递归读取 `image_url` / `image_url.url` / `images[]` / `image_urls[]` 等字段;若返回 `/generated/...` 或 `generated/...` 相对路径,先按 cmhub Base URL 补全为完整 `http(s)` URL,再进入原有公网 URL 安全校验和下载。 @@ -444,7 +447,7 @@ class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图 class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载 -class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台生成商品卖点与画面要求 +class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台用本地原图调用图片理解,生成商品卖点与画面要求 class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单 @@ -469,7 +472,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 ⑤ 设置当前要点(T-501): - `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。 -- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名,显示托管档位、展示名、扣点和需参考图提示;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。 +- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图/图片理解并过滤未计价别名,其中图片理解额外要求 `requires_image=true`;显示托管档位、展示名、扣点和需参考图提示。已保存但当前模型列表不可用的图片理解别名必须保留并标明暂不可用;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。 - T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。 - `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。 - 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 diff --git a/docs/cmhub-integration-design.md b/docs/cmhub-integration-design.md index aa0629c..5cfd199 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -13,6 +13,7 @@ > **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。**T-530 已落地**:保存/请求前规整 Base URL 到网关根,HTTP 404 映射为 `not_found` 并给出中文排障提示。 > **v3.5 修订(2026-07-08,T-553 待实现)**:cmhub 生图稳定性口径调整为连接超时默认 66 秒、生图请求和 `image_url` 下载读取等待统一 900 秒,与线上 Nginx/Gunicorn 的长等待窗口对齐;生图读超时仍不自动重发,避免重复扣点。 > **v3.6 修订(2026-07-08,T-564)**:cmhub 已新增异步生图任务接口,②批量生图改为 `POST /api/v1/generate/image/tasks` submit + `GET /api/v1/generate/image/tasks/{task_id}` poll;cmshopee 持久化 `tasks.image_task_id/image_task_key`,支持停止/超时/重启后续查,避免 900 秒同步长连接和读超时重复扣点。旧同步 `POST /api/v1/generate/image` 仅保留给单独 `gen_cover()` 兼容/回滚路径。 +> **v3.7 修订(2026-07-17,T-645)**:⑥「商品套图」的 AI帮写接入独立图片理解能力:使用 `vision_alias` 调 `POST /api/v1/analyze/images`,不复用标题接口或 `title_alias`。请求最多8张有序本地原图,单图不超过10MiB、总计不超过32MiB;读取等待120秒且读超时不自动重发,避免结果未确认时重复扣点。 ## 1. 背景与目标 @@ -59,6 +60,7 @@ "base_url": "https://", // 网关根地址,请求时拼 /api/v1/... "title_alias": "title-standard", // 生文能力别名 "image_alias": "image-hd", // 生图能力别名 + "vision_alias": "vision-standard",// ⑥AI帮写图片理解能力别名 "connect_timeout": 66, "check_balance_before_batch": false }, @@ -69,7 +71,7 @@ - **cmhub API Key 不进 `data/config.json`**(避免与其它设置混放、避免误提交)。固定存到 `data/config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`data/config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。 - **`ai_models.json` 去留**:`direct` 模式继续用;`cmhub` 模式不读它。文件保留但标记 legacy。 -- **默认 backend 与配置不完整处理(T-529 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②生成真正调用时如果 `base_url`/Key/别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但普通 UI 不提供入口。 +- **默认 backend 与配置不完整处理(T-529/T-645 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`,`vision_alias` 默认 `vision-standard`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②或⑥真正调用时如果 `base_url`/Key/对应别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但⑥AI帮写不提供 direct 回退。 - **Base URL 必须是网关根**:只填 `https://`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer `,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。 ### 4.2 `gen_title` 改造(cmhub 分支) @@ -86,6 +88,13 @@ - **响应**:解析 `titles`,取 `titles[0]`(当前一条任务要一个新标题);空列表/空串按现有语义抛 `AIError("AI 返回为空标题")`。 - 不复用现有 `_call_with_retry` 的一刀切重试逻辑;cmhub 分支新增专用 HTTP helper,优先使用项目已依赖的 `requests`,传 `timeout=(connect_timeout, read_timeout)` 以区分连接超时和读超时。`model_used`/`points_cost`/`points_balance`/`call_id` 不改变返回值,通过 `on_step`/事件回调上报给 `generate_batch` 与 GUI worker;T-526 只保证 metadata 事件完整传出,不直接要求写 GUI `run_logs`,T-528 再由 GUI worker 脱敏写 `run_logs` 和展示余额;不入 Excel。 +### 4.2a ⑥商品套图图片理解(T-645) + +- **请求**:仅 `backend=cmhub` 时由 `analyze_product_images()` 调 `POST {base_url}/api/v1/analyze/images`,头沿用 Bearer Key,体为 `{prompt, model: vision_alias, images:[{image_base64:data_url}], parameters:{temperature:0.2}}`。`images` 按⑥项目原图 `source_order` 顺序传入,数量1至8;单图超过10MiB、总计超过32MiB、路径缺失或无图时在本地阻断,不发请求。 +- **响应**:只接收可编辑文本和白名单元数据 `alias/model_used/points_cost/points_balance/call_id`;不保存 base64、完整提示词、图片路径、接口URL或 Provider 原始响应。成功由⑥状态栏显示“已理解N张商品原图、图片理解扣点、当前余额”。 +- **超时/取消**:连接等待沿用 `connect_timeout`,读取等待120秒;一次 `read_timeout`、连接中断的结果均视为未确认,不自动重发。取消只在发请求前或返回后协作生效,不强杀网络线程,也不声称未扣点。 +- **边界**:该能力不改 `gen_title()`、`gen_cover()`、②批量生成状态或⑥正式套图生图 job;已有卖点只作为补充要求,模型必须以图片可见信息为主,避免虚构不可确认的规格、认证、价格和物流信息。 + ### 4.3 `gen_cover` 改造(cmhub 分支) 返回值不变(仍返回已存 JPEG 路径),现有调用保持兼容;允许新增可选事件回调参数承载计费元数据。内层: @@ -139,9 +148,9 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**( 对接文档已把别名发现从"未来提供"落地为**真实接口**(第 4 个接口,鉴权同为 Bearer Key): -- 响应 `{models:[{alias, operation_type:"title"|"image", capabilities[], requires_image:bool, pricing_status:"priced"|"unpriced", prices:[{resolution, points_cost}]}]}`——只含别名侧信息,不含具体模型名/URL/密钥。 -- **⑤设置的别名由手填改为动态下拉**:新增 `app/appconfig.py`(或 `app/ai.py`)helper `fetch_cmhub_models(base_url, api_key)`,⑤按 `operation_type` 拆成「生文别名 / 生图别名」两个下拉;`requires_image` 供 UI 提示(cmshopee 生图恒传旧封面 base64,天然满足);`pricing_status="unpriced"` 的别名**不放入下拉**(直接调用会 `no_pricing_rule`);单价用 `prices` 展示。 -- 拉取时机:⑤ 打开或用户点「测试连接/刷新别名」时拉一次,选中的别名仍持久化到 `config.json` 的 `ai.cmhub.title_alias/image_alias`(网关临时不可达时用已存值)。 +- 响应 `{models:[{alias, operation_type:"title"|"image"|"vision", capabilities[], requires_image:bool, pricing_status:"priced"|"unpriced", prices:[{resolution, points_cost}]}]}`——只含别名侧信息,不含具体模型名/URL/密钥。 +- **⑤设置的别名由手填改为动态下拉**:新增 `app/appconfig.py`(或 `app/ai.py`)helper `fetch_cmhub_models(base_url, api_key)`,⑤按 `operation_type` 拆成「生文别名 / 生图别名 / 图片理解别名」三个下拉;图片理解下拉额外只接受 `requires_image=true` 且 `pricing_status="priced"` 的别名。已保存但本次模型列表不可用的值必须保留并标注,不可无声清空;`requires_image` 也供生图 UI 提示(cmshopee 生图恒传旧封面 base64,天然满足);`pricing_status="unpriced"` 的别名**不放入下拉**(直接调用会 `no_pricing_rule`);单价用 `prices` 展示。 +- 拉取时机:⑤打开或用户点「测试连接/刷新别名」时拉一次,选中的别名仍持久化到 `config.json` 的 `ai.cmhub.title_alias/image_alias/vision_alias`(网关临时不可达时用已存值)。 - 这**关闭了原待确认 #1(别名清单)**——不再需部署方单独提供;仅 Base URL 仍待部署方给。 ## 5. 各模块改动点 @@ -176,7 +185,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**( ## 8. 迁移与回退 -- T-529 后普通配置默认 `backend=cmhub`;显式手工配置 `backend=direct` 仍可作为内部回滚路径。⑤普通 UI 不再提供后端切换,用户只需配好 `base_url + Key + 两个别名`。 +- T-529/T-645 后普通配置默认 `backend=cmhub`;显式手工配置 `backend=direct` 仍可作为内部回滚路径,但⑥AI帮写必须使用 cmhub 图片理解。⑤普通 UI 不再提供后端切换,用户只需配好 `base_url + Key + 三类别名`。 - 回退:`backend=direct` 立即切回本地直连,`ai_models.json` 仍有效。 - 灰度:可先在 ② 单条生成上验证 cmhub 联通与计费,再放批量。 diff --git a/docs/routes.md b/docs/routes.md index 2bf8327..cd50ccc 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,8 +14,8 @@ | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 | | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | -| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | -| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 | +| ⑤ 设置 | cmhub 网关/API Key、生文/生图/图片理解别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | +| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,AI帮写理解图片内容,按套图分类异步生图,查看历史与重试 | 本地生成,中 | 旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。 @@ -207,7 +207,7 @@ - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - T-644 后「历史生成」打开当前店铺、当前商品专属的非模态「历史生成记录」窗口,主结果区不切换。窗口按生成轮次倒序分组,显示时间、成功/失败/停止数量和“当前”标记;当前槽位有重试时只展示最新图片,并在提示中说明重试次数。默认加载最近20轮,底部可加载更多;旧版记录、全失败轮和本地文件缺失项都保留中文说明。窗口只读:单击选中、双击自适应预览、右键预览/复制路径/打开所在文件夹;不提供删除、重试或切换当前轮操作。重复点击同一商品复用窗口;任务关闭时窗口一起关闭。 -- AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 +- AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,超过8张时状态提示只使用前8张。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 ## 流程导航 diff --git a/docs/tasks/T-645.md b/docs/tasks/T-645.md index 44babf5..5d85019 100644 --- a/docs/tasks/T-645.md +++ b/docs/tasks/T-645.md @@ -3,7 +3,7 @@ id: T-645 title: 商品套图AI帮写接入cmhub图片理解 phase: 7 deps: [T-637] -status: TODO +status: DONE created: 2026-07-17 --- @@ -122,3 +122,5 @@ git diff --check ## 执行记录 - 2026-07-17:根据真实 cmhub 网关验证创建任务。`vision-standard` 已出现在模型发现接口中,声明为已定价的独立 `vision` 能力;单张商品主图理解请求成功返回中文商品事实,耗时约44秒、扣1点。待按本任务接入桌面端代码。 +- 2026-07-17:已实现 `ai.analyze_product_images()`,通过 `POST /api/v1/analyze/images` 使用独立 `vision_alias`,请求前校验 1 至 8 张本地原图、单图 10MiB、总计 32MiB,并以 120 秒读取等待和不自动重试处理结果未确认的超时。图片理解的文本与白名单计费元数据由商品套图 AI 帮写 worker 回填,保留已有的用户编辑冲突确认和协作取消语义。 +- 2026-07-17:⑤设置已增加「图片理解别名」并按已定价、需要图片的 `vision` 模型筛选;旧配置自动补 `vision-standard`。已同步更新架构、API、路由及 cmhub 集成设计文档。验证通过:`py -3.10 -m unittest tests.test_ai tests.test_appconfig tests.test_gui tests.test_product_suite_gui tests.test_workers`(318 项)、`py -3.10 -m unittest discover -s tests`(564 项)、`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 47bfa4a..e84b940 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -97,6 +97,7 @@ class AITests(TempDirMixin, unittest.TestCase): "base_url": "https://cmhub.example.com", "title_alias": "title-standard", "image_alias": "image-hd", + "vision_alias": "vision-standard", "connect_timeout": 3, "download_with_curl": "false", "check_balance_before_batch": False, @@ -454,6 +455,144 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + 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) + first = os.path.join(temp_dir, "first.png") + second = os.path.join(temp_dir, "second.jpg") + with open(first, "wb") as fh: + fh.write(b"first-image") + with open(second, "wb") as fh: + fh.write(b"second-image") + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return _RequestsResponse( + { + "text": "浅绿色连帽上衣,突出宽松版型与日常穿搭场景。", + "alias": "vision-standard", + "model_used": "vision-provider", + "points_cost": 1, + "points_balance": 231, + "call_id": "vision-call-1", + } + ) + + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): + result = ai.analyze_product_images( + "补充卖点:避免夸大", + "输出语言:繁体中文", + [first, second], + config=cfg, + cmhub_config_path=key_path, + ) + + self.assertEqual("浅绿色连帽上衣,突出宽松版型与日常穿搭场景。", result["text"]) + self.assertEqual(2, result["image_count"]) + self.assertEqual(1, result["metadata"]["points_cost"]) + self.assertEqual(231, result["metadata"]["points_balance"]) + self.assertNotIn("image_base64", result) + self.assertEqual("POST", calls[0][0]) + self.assertEqual( + "https://cmhub.example.com/api/v1/analyze/images", + calls[0][1], + ) + payload = calls[0][2]["json"] + self.assertEqual("vision-standard", payload["model"]) + self.assertEqual(2, len(payload["images"])) + self.assertTrue(payload["images"][0]["image_base64"].startswith("data:image/png;base64,")) + self.assertTrue(payload["images"][1]["image_base64"].startswith("data:image/jpeg;base64,")) + self.assertEqual({"temperature": 0.2}, payload["parameters"]) + self.assertEqual((3, ai.CMHUB_VISION_READ_TIMEOUT_SECONDS), calls[0][2]["timeout"]) + + self.assert_removed(temp_dir) + + 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) + source = os.path.join(temp_dir, "source.jpg") + with open(source, "wb") as fh: + fh.write(b"source") + cfg["ai"]["cmhub"]["vision_alias"] = "" + + with self.assertRaises(ai.CMHubError) as raised: + ai.analyze_product_images( + "提示", + "输出语言:繁体中文", + [source], + config=cfg, + cmhub_config_path=key_path, + ) + + self.assertEqual("cmhub_not_configured", raised.exception.code) + self.assertIn("图片理解别名", str(raised.exception)) + + cfg["ai"]["cmhub"]["vision_alias"] = "vision-standard" + paths = [] + for index in range(ai.CMHUB_VISION_MAX_IMAGES + 1): + path = os.path.join(temp_dir, "source-%d.jpg" % index) + with open(path, "wb") as fh: + fh.write(b"image") + paths.append(path) + with mock.patch.object(ai._cmhub_session(), "request") as request: + with self.assertRaises(ai.AIError) as too_many: + ai.analyze_product_images( + "提示", + "输出语言:繁体中文", + paths, + config=cfg, + cmhub_config_path=key_path, + ) + self.assertIn("最多支持", str(too_many.exception)) + request.assert_not_called() + + oversized = os.path.join(temp_dir, "oversized.jpg") + with open(oversized, "wb") as fh: + fh.truncate(ai.CMHUB_VISION_MAX_IMAGE_BYTES + 1) + with mock.patch.object(ai._cmhub_session(), "request") as request: + with self.assertRaises(ai.AIError) as too_large: + ai.analyze_product_images( + "提示", + "输出语言:繁体中文", + [oversized], + config=cfg, + cmhub_config_path=key_path, + ) + self.assertIn("超过10MiB", str(too_large.exception)) + request.assert_not_called() + + self.assert_removed(temp_dir) + + def test_cmhub_analyze_product_images_read_timeout_is_not_retried(self): + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + source = os.path.join(temp_dir, "source.jpg") + with open(source, "wb") as fh: + fh.write(b"source") + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + raise ai.requests.exceptions.ReadTimeout("slow") + + with mock.patch.object(ai._cmhub_session(), "request", side_effect=fake_request): + with self.assertRaises(ai.CMHubError) as raised: + ai.analyze_product_images( + "提示", + "输出语言:繁体中文", + [source], + config=cfg, + cmhub_config_path=key_path, + ) + + self.assertEqual("read_timeout", raised.exception.code) + self.assertIn("结果未确认", str(raised.exception)) + self.assertEqual(1, len(calls)) + self.assertEqual((3, ai.CMHUB_VISION_READ_TIMEOUT_SECONDS), calls[0][2]["timeout"]) + + self.assert_removed(temp_dir) + def test_cmhub_gen_cover_downloads_image_url_safely(self): try: from PIL import Image diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 20dcfc0..cb0ebcb 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -28,6 +28,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assertEqual(os.path.join(temp_dir, "config", "cmhub.json"), config["cmhub_config_path"]) self.assertEqual(240, appconfig.response_timeout(config)) self.assertFalse(appconfig.ai_config(config)["generate_cover"]) + self.assertEqual("vision-standard", appconfig.cmhub_config(config)["vision_alias"]) self.assertEqual("title", appconfig.ai_generate_mode(config)) self.assertEqual("title", appconfig.shopee_update_config(config)["update_mode"]) self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(config)) @@ -153,6 +154,34 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_vision_alias_defaults_for_legacy_config_and_preserves_saved_value(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + with open(config_path, "w", encoding="utf-8") as fh: + json.dump( + { + "ai": { + "backend": "cmhub", + "cmhub": { + "base_url": "https://cmhub.example.com", + "title_alias": "title-standard", + "image_alias": "image-hd", + }, + } + }, + fh, + ensure_ascii=False, + ) + + legacy = appconfig.load_config(config_path) + self.assertEqual("vision-standard", appconfig.cmhub_config(legacy)["vision_alias"]) + + legacy["ai"]["cmhub"]["vision_alias"] = "vision-custom" + saved = appconfig.save_config(legacy, path=config_path) + self.assertEqual("vision-custom", appconfig.cmhub_config(saved)["vision_alias"]) + + self.assert_removed(temp_dir) + def test_shopee_update_legacy_parallel_config_is_migrated(self): with self.make_temp_dir() as temp_dir: config_path = os.path.join(temp_dir, "config.json") diff --git a/tests/test_gui.py b/tests/test_gui.py index 3e52fa3..ca28b1c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2798,6 +2798,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode()) self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData()) self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData()) + self.assertEqual("vision-standard", tab.cmhub_vision_alias_combo.currentData()) tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/") tab.cmhub_api_key_edit.setText("sk-new-secret") @@ -2819,9 +2820,17 @@ class GuiTests(TempDirMixin, unittest.TestCase): "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 5}], }, + { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 1}], + }, ], title_selected="title-standard", image_selected="image-standard", + vision_selected="vision-standard", ) with mock.patch("app.gui.QMessageBox.warning") as warning, \ @@ -2837,6 +2846,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("https://cmhub.example.com", tab.cmhub_base_url_edit.text()) self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"]) self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"]) + self.assertEqual("vision-standard", saved["ai"]["cmhub"]["vision_alias"]) self.assertEqual(12, saved["ai"]["cmhub"]["connect_timeout"]) self.assertTrue(saved["ai"]["cmhub"]["check_balance_before_batch"]) self.assertEqual( @@ -2856,6 +2866,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "base_url": "https://cmhub.example.com", "title_alias": "title-saved", "image_alias": "image-saved", + "vision_alias": "vision-saved", "connect_timeout": 10, "check_balance_before_batch": False, } @@ -2889,6 +2900,27 @@ class GuiTests(TempDirMixin, unittest.TestCase): "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 5}], }, + { + "alias": "vision-priced", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 1}], + }, + { + "alias": "vision-without-image", + "operation_type": "vision", + "requires_image": False, + "pricing_status": "priced", + "prices": [{"points_cost": 1}], + }, + { + "alias": "vision-free", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "unpriced", + "prices": [], + }, ], } ) @@ -2901,6 +2933,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.cmhub_image_alias_combo.itemText(index) for index in range(tab.cmhub_image_alias_combo.count()) ] + vision_aliases = [ + tab.cmhub_vision_alias_combo.itemData(index) + for index in range(tab.cmhub_vision_alias_combo.count()) + ] self.assertIn("title-priced", title_aliases) self.assertIn("title-saved", title_aliases) self.assertNotIn("title-free", title_aliases) @@ -2908,7 +2944,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0)) self.assertTrue(any("需参考图" in label for label in image_labels)) self.assertTrue(any("默认档" in label for label in image_labels)) + self.assertIn("vision-priced", vision_aliases) + self.assertIn("vision-saved", vision_aliases) + self.assertNotIn("vision-without-image", vision_aliases) + self.assertNotIn("vision-free", vision_aliases) self.assertIn("余额 55", tab.cmhub_result_label.text()) + self.assertIn("当前已保存值暂不可用", tab.cmhub_result_label.text()) self.assert_removed(temp_dir) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 06c9ef7..0bc2fc6 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -929,6 +929,61 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_ai_write_uses_first_eight_originals_in_source_order(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 9) + 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) + captured = {} + + class _Signal: + def connect(self, callback): + self.callback = callback + + class _AiWriteWorker: + def __init__(self, instruction, context, **kwargs): + captured["instruction"] = instruction + captured["context"] = context + captured["image_paths"] = list(kwargs.get("image_paths") or []) + self.finished = _Signal() + self.cancelled = _Signal() + self.failed = _Signal() + + def cancel(self): + pass + + with mock.patch( + "app.gui.tabs.product_suite.ProductSuiteAiWriteWorker", + _AiWriteWorker, + ), mock.patch.object(tab, "_start_thread", return_value=object()), mock.patch.object( + tab, "_status" + ) as status: + tab.start_ai_write() + + self.assertEqual( + [asset.local_path for asset in assets[:8]], + captured["image_paths"], + ) + self.assertTrue( + any( + "已使用前8张商品原图进行理解" in str(call.args[0]) + for call in status.call_args_list + ) + ) + state.ai_worker = None + state.ai_thread = None + state.ai_started_at = None + tab._apply_running_state(state) + + self.assert_removed(temp_dir) + def test_prompt_edit_expands_shrinks_and_reflows_without_internal_scrollbars(self): edit = AutoHeightPlainTextEdit() self.addCleanup(edit.close) @@ -2219,6 +2274,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): def __init__(self, instruction, context, **kwargs): captured["instruction"] = instruction captured["context"] = context + captured["image_paths"] = list(kwargs.get("image_paths") or []) self.finished = _Signal() self.cancelled = _Signal() self.failed = _Signal() @@ -2233,6 +2289,16 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): tab.start_ai_write() self.assertIn("未绑定商品", captured["context"]) self.assertNotIn("draft_", captured["context"]) + self.assertEqual( + [ + image_studio.list_assets( + draft.id, + kind=image_studio.ASSET_KIND_ORIGINAL, + path=config["db_path"], + )[0].local_path + ], + captured["image_paths"], + ) state.ai_worker = None state.ai_thread = None state.ai_started_at = None diff --git a/tests/test_workers.py b/tests/test_workers.py index 04864b3..d46da78 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -20,6 +20,7 @@ from app.workers import BaseWorker, run_worker from app.gui.workers import ( ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, + ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, ) @@ -198,6 +199,36 @@ class WorkerTests(unittest.TestCase): self.assertEqual({"asset_id": 12, "cancelled": True}, summary) + def test_product_suite_ai_write_worker_uses_image_analysis_not_title_generation(self): + worker = ProductSuiteAiWriteWorker( + "补充要求", + "输出语言:繁体中文", + image_paths=["first.jpg", "second.jpg"], + config={"ai": {"backend": "cmhub"}}, + cmhub_config_path="cmhub.json", + ) + expected = { + "text": "根据图片整理的商品卖点", + "image_count": 2, + "metadata": {"points_cost": 1, "points_balance": 231}, + } + + with mock.patch( + "app.gui.workers.ai.analyze_product_images", + return_value=expected, + ) as analyze, mock.patch("app.gui.workers.ai.gen_title") as gen_title: + result = worker.execute() + + analyze.assert_called_once_with( + "补充要求", + "输出语言:繁体中文", + ["first.jpg", "second.jpg"], + config={"ai": {"backend": "cmhub"}}, + cmhub_config_path="cmhub.json", + ) + gen_title.assert_not_called() + self.assertEqual(expected, result) + def test_product_suite_worker_writes_generation_round_and_stable_slots(self): with tempfile.TemporaryDirectory() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db")