diff --git a/app/cmhub_models.py b/app/cmhub_models.py index 469c564..7b7937e 100644 --- a/app/cmhub_models.py +++ b/app/cmhub_models.py @@ -2,6 +2,11 @@ from __future__ import annotations +import copy +from decimal import Decimal, InvalidOperation +import threading +import time + TIER_DEFAULT = "default" TIER_HIGH_QUALITY = "high_quality" @@ -20,6 +25,154 @@ TIER_DESCRIPTIONS = { } +# The catalog is deliberately process-local. It only caches public model metadata, +# never the API key or a price estimate chosen by a user. +MODEL_CATALOG_CACHE_TTL_SECONDS = 300 +_MODEL_CATALOG_CACHE = {} +_MODEL_CATALOG_CACHE_LOCK = threading.RLock() +_CATALOG_ALL_ALIASES_KEY = "*" +_PRICE_CONDITION_KEYS = ( + "resolution", + "name", + "quality", + "size", + "ratio", + "aspect_ratio", + "tier", + "condition", + "conditions", + "min_images", + "max_images", + "image_count", +) + + +def cache_model_catalog(base_url, models, *, now=None): + """Cache one fetched catalog in memory for all aliases it contains.""" + + normalized_base_url = _catalog_base_url(base_url) + if not normalized_base_url: + return + copied_models = [copy.deepcopy(model) for model in list(models or []) if isinstance(model, dict)] + fetched_at = float(time.monotonic() if now is None else now) + entry = (fetched_at, copied_models) + aliases = { + str(model.get("alias") or "").strip() + for model in copied_models + if str(model.get("alias") or "").strip() + } + with _MODEL_CATALOG_CACHE_LOCK: + _MODEL_CATALOG_CACHE[(normalized_base_url, _CATALOG_ALL_ALIASES_KEY)] = entry + for alias in aliases: + _MODEL_CATALOG_CACHE[(normalized_base_url, alias)] = entry + + +def cached_model_catalog(base_url, alias="", *, max_age_seconds=None, now=None): + """Return a fresh catalog copy for a normalized gateway/alias pair, if available.""" + + normalized_base_url = _catalog_base_url(base_url) + normalized_alias = str(alias or "").strip() + if not normalized_base_url: + return None + max_age = MODEL_CATALOG_CACHE_TTL_SECONDS if max_age_seconds is None else max_age_seconds + try: + max_age = float(max_age) + except (TypeError, ValueError): + return None + current = float(time.monotonic() if now is None else now) + keys = [(normalized_base_url, normalized_alias)] if normalized_alias else [] + keys.append((normalized_base_url, _CATALOG_ALL_ALIASES_KEY)) + with _MODEL_CATALOG_CACHE_LOCK: + for key in keys: + entry = _MODEL_CATALOG_CACHE.get(key) + if entry is None: + continue + fetched_at, models = entry + if current - fetched_at > max_age: + continue + return [copy.deepcopy(model) for model in models] + return None + + +def clear_model_catalog_cache(): + """Clear the transient catalog cache. Intended for tests and gateway changes.""" + + with _MODEL_CATALOG_CACHE_LOCK: + _MODEL_CATALOG_CACHE.clear() + + +def unambiguous_points_cost(models, alias, operation_type, *, requires_image=None): + """Return the unique unconditional price for one configured alias, otherwise ``None``.""" + + normalized_alias = str(alias or "").strip() + normalized_operation = str(operation_type or "").strip().lower() + matches = [ + model + for model in list(models or []) + if isinstance(model, dict) and str(model.get("alias") or "").strip() == normalized_alias + ] + if len(matches) != 1: + return None + model = matches[0] + if str(model.get("operation_type") or "").strip().lower() != normalized_operation: + return None + if str(model.get("pricing_status") or "").strip().lower() != "priced": + return None + if requires_image is True and model.get("requires_image") is not True: + return None + if requires_image is False and model.get("requires_image") is not False: + return None + prices = model.get("prices") + if not isinstance(prices, list) or len(prices) != 1 or not isinstance(prices[0], dict): + return None + price = prices[0] + if any(_has_value(price.get(key)) for key in _PRICE_CONDITION_KEYS): + return None + return _points_cost_value(price) + + +def format_points_cost(value): + """Format a catalog points value without inventing precision.""" + + if value is None: + return "" + decimal_value = _points_cost_decimal(value) + if decimal_value is None: + return "" + text = format(decimal_value.normalize(), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + +def _catalog_base_url(base_url): + return str(base_url or "").strip().rstrip("/").lower() + + +def _has_value(value): + return value not in (None, "", [], {}, ()) + + +def _points_cost_value(price): + if not isinstance(price, dict): + return None + if "points_cost" not in price: + return None + return _points_cost_decimal(price.get("points_cost")) + + +def _points_cost_decimal(value): + if isinstance(value, bool) or value is None: + return None + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + if not decimal_value.is_finite() or decimal_value < 0: + return None + return decimal_value + + def normalize_tier(value): text = str(value or "").strip().lower().replace("-", "_").replace(" ", "_") if text in {"high", "quality", "high_quality", "premium", "pro", "sol"}: diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 85ed23e..082918f 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -20,6 +20,7 @@ if QT_IMPORT_ERROR is None: from .workers import ( AccountLoginCheckWorker, AIModelTestWorker, + CMHubModelCatalogWorker, CMHubSettingsWorker, ApplyWorker, CollectWorker, diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index d491faf..aa470b5 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -56,6 +56,7 @@ from ... import ( accounts, ai, appconfig, + cmhub_models, diagnostics, image_studio, image_studio_images, @@ -67,6 +68,7 @@ from ..image_preview import ImagePreviewDialog from ..product_suite_prompt_dialog import ProductSuitePromptDialog from ..widgets import _emit_status, run_worker from ..workers import ( + CMHubModelCatalogWorker, ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, ProductSuiteAiWriteWorker, @@ -1834,6 +1836,9 @@ class SuiteTaskState: import_created_draft: bool = False ai_worker: object = None ai_thread: object = None + ai_price_worker: object = None + ai_price_thread: object = None + ai_confirmation_open: bool = False download_queue: list = field(default_factory=list) downloads: dict = field(default_factory=dict) download_tokens: dict = field(default_factory=dict) @@ -2519,8 +2524,9 @@ class ProductSuiteTab(QWidget): return state.generation_stop_requested = True state.worker.cancel() - if state.ai_worker is not None: - state.ai_worker.cancel() + for worker in (state.ai_worker, state.ai_price_worker): + if worker is not None: + worker.cancel() if state.pull_running(): state.pull_stop_requested = True state.pull_cleanup_mode = "keep" @@ -4074,15 +4080,171 @@ class ProductSuiteTab(QWidget): state = self._displayed_state if state is None: return + if state.ai_confirmation_open: + self._status("正在等待AI帮写确认", "info") + return if state.ai_worker is not None: self._status("当前套图任务正在AI帮写", "info") return - local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] - if not local_assets: + if state.ai_price_worker is not None: + self._status("正在读取图片理解预计扣点", "info") + return + selected_assets = self._ai_write_selected_assets(state) + if not selected_assets: self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。") return - selected_assets = local_assets[: ai.CMHUB_VISION_MAX_IMAGES] self._save_controls_to_state(state) + selected_asset_ids = tuple(int(asset.id) for asset in selected_assets) + catalog_params = self._ai_write_catalog_params() + cached_models = ( + cmhub_models.cached_model_catalog(catalog_params["base_url"], catalog_params["alias"]) + if catalog_params is not None + else None + ) + if cached_models is not None: + self._confirm_ai_write_request( + state, + selected_asset_ids, + self._ai_write_points_cost(cached_models, catalog_params["alias"]), + ) + return + if catalog_params is None: + self._confirm_ai_write_request(state, selected_asset_ids, None) + return + self._start_ai_write_catalog_lookup(state, selected_asset_ids, catalog_params) + + def _ai_write_selected_assets(self, state): + return [ + asset + for asset in self._original_assets(state) + if _asset_usable(asset) + ][: ai.CMHUB_VISION_MAX_IMAGES] + + def _ai_write_catalog_params(self): + 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() + api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path) + except Exception: + return None + if not base_url or not alias or not api_key: + return None + return { + "base_url": base_url, + "alias": alias, + "api_key": api_key, + "connect_timeout": cmhub_config.get("connect_timeout", 10), + "use_system_proxy": bool(appconfig.ai_config(self.config).get("use_system_proxy")), + } + + @staticmethod + def _ai_write_points_cost(models, alias): + return cmhub_models.unambiguous_points_cost( + models, + alias, + "vision", + requires_image=True, + ) + + def _start_ai_write_catalog_lookup(self, state, selected_asset_ids, params): + worker = CMHubModelCatalogWorker( + params["base_url"], + params["api_key"], + connect_timeout=params["connect_timeout"], + use_system_proxy=params["use_system_proxy"], + ) + state.ai_price_worker = worker + worker.finished.connect( + lambda result, state=state, asset_ids=selected_asset_ids, alias=params["alias"]: + self._on_ai_write_catalog_finished(state, asset_ids, alias, result) + ) + worker.cancelled.connect( + lambda result, state=state: self._on_ai_write_catalog_cancelled(state, result) + ) + state.ai_price_thread = self._start_thread(worker, "商品套图读取扣点") + if state is self._displayed_state: + self._apply_running_state(state) + self._status("正在读取图片理解预计扣点", "info") + + def _on_ai_write_catalog_finished(self, state, selected_asset_ids, alias, result): + state.ai_price_worker = None + state.ai_price_thread = None + if not self._is_open_suite_state(state): + return + if result.get("ok") is False: + self._status("暂时无法取得图片理解预计扣点,实际以网关返回为准", "warning") + self._confirm_ai_write_request(state, selected_asset_ids, None) + elif not result.get("cancelled"): + self._confirm_ai_write_request( + state, + selected_asset_ids, + self._ai_write_points_cost(result.get("models") or [], alias), + ) + if state is self._displayed_state: + self._apply_running_state(state) + + def _on_ai_write_catalog_cancelled(self, state, result): + state.ai_price_worker = None + state.ai_price_thread = None + if self._is_open_suite_state(state): + self._status("已取消读取图片理解预计扣点", "warning") + if state is self._displayed_state: + self._apply_running_state(state) + + def _is_open_suite_state(self, state): + return self._states.get(getattr(state, "key", None)) is state + + def _confirm_ai_write_request(self, state, selected_asset_ids, points_cost): + if not self._is_open_suite_state(state) or state.ai_worker is not None: + return + current_asset_ids = tuple(int(asset.id) for asset in self._ai_write_selected_assets(state)) + if current_asset_ids != tuple(selected_asset_ids): + self._status("商品原图已变化,请重新点击AI帮写", "warning") + return + message = self._ai_write_confirmation_message(len(current_asset_ids), points_cost) + state.ai_confirmation_open = True + accepted = False + try: + box = QMessageBox(self) + box.setIcon(QMessageBox.Question) + box.setWindowTitle("开始AI帮写") + box.setText(message) + start_button = box.addButton("开始AI帮写", QMessageBox.AcceptRole) + cancel_button = box.addButton("取消", QMessageBox.RejectRole) + box.setDefaultButton(cancel_button) + box.setEscapeButton(cancel_button) + box.exec() + accepted = box.clickedButton() is start_button + finally: + state.ai_confirmation_open = False + if not accepted: + if state is self._displayed_state: + self._apply_running_state(state) + return + self._start_confirmed_ai_write(state, selected_asset_ids) + + @staticmethod + def _ai_write_confirmation_message(image_count, points_cost): + lines = [ + "本次会理解当前商品前%d张可用商品原图(最多%d张),并生成商品卖点与要求。" + % (int(image_count), ai.CMHUB_VISION_MAX_IMAGES), + ] + price_text = cmhub_models.format_points_cost(points_cost) + if price_text: + lines.append("预计扣点:%s 点,实际以网关返回为准。" % price_text) + else: + lines.append("暂时无法取得预计扣点,实际以网关返回为准。") + lines.append("开始后可取消本地等待;请求已提交到网关时,仍可能产生扣点。") + return "\n".join(lines) + + def _start_confirmed_ai_write(self, state, selected_asset_ids): + if not self._is_open_suite_state(state) or state.ai_worker is not None: + return + selected_assets = self._ai_write_selected_assets(state) + if tuple(int(asset.id) for asset in selected_assets) != tuple(selected_asset_ids): + self._status("商品原图已变化,请重新点击AI帮写", "warning") + return context = ( "商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s" % ( @@ -4114,13 +4276,16 @@ class ProductSuiteTab(QWidget): if state is self._displayed_state: self._apply_running_state(state) message = "AI帮写已开始,可切换到其他套图任务" - if len(local_assets) > ai.CMHUB_VISION_MAX_IMAGES: + if len([asset for asset in self._original_assets(state) if _asset_usable(asset)]) > 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 - if state is not None and state.ai_worker is not None: + if state is not None and state.ai_price_worker is not None: + state.ai_price_worker.cancel() + self._status("已请求取消读取图片理解预计扣点", "warning") + elif state is not None and state.ai_worker is not None: state.ai_worker.cancel() self._status("已请求取消AI帮写", "warning") @@ -4946,7 +5111,7 @@ class ProductSuiteTab(QWidget): "QPushButton:hover { background: #245fce; }" ) self._refresh_totals(state) - ai_running = state.ai_worker is not None + ai_running = state.ai_worker is not None or state.ai_price_worker is not None self.ai_write_button.setEnabled(not ai_running and not generation_running) self.ai_cancel_button.setVisible(ai_running) self._update_context_actions(state) @@ -4967,7 +5132,9 @@ class ProductSuiteTab(QWidget): ) self.progress_bar.setRange(0, max(1, state.total)) self.progress_bar.setValue(min(state.done, max(1, state.total))) - if state.ai_worker is not None and state.ai_started_at is not None: + if state.ai_price_worker is not None: + self.ai_write_button.setText("读取扣点中") + elif state.ai_worker is not None and state.ai_started_at is not None: ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at)) self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed) else: @@ -5250,7 +5417,13 @@ class ProductSuiteTab(QWidget): for state in list(self._states.values()): self._flush_prompt_save(state) for state in list(self._states.values()) + list(self._retired_states): - for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker): + for worker in ( + state.worker, + state.pull_worker, + state.import_worker, + state.ai_worker, + state.ai_price_worker, + ): if worker is not None and hasattr(worker, "cancel"): worker.cancel() for worker, thread in list(state.downloads.values()): diff --git a/app/gui/workers.py b/app/gui/workers.py index 782c17b..e38e28c 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -12,7 +12,15 @@ try: except ModuleNotFoundError: # pragma: no cover - GUI import guard Signal = None -from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images +from .. import ( + ai, + appconfig, + cmhub_models, + image_studio, + image_studio_export, + image_studio_generation, + image_studio_images, +) from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts from .widgets import * @@ -484,6 +492,38 @@ class ProductSuiteAiWriteWorker(BaseWorker): } +class CMHubModelCatalogWorker(BaseWorker): + """Fetch a transient cmhub model catalog without blocking a product workflow.""" + + def __init__( + self, + base_url, + api_key, + *, + connect_timeout=10, + use_system_proxy=False, + ): + super().__init__() + self.base_url = appconfig.normalize_cmhub_base_url(base_url) + self.api_key = str(api_key or "") + self.connect_timeout = max(1, int(connect_timeout or 10)) + self.use_system_proxy = bool(use_system_proxy) + + def execute(self): + if self.should_cancel(): + return {"cancelled": True} + models = ai.fetch_cmhub_models( + self.base_url, + self.api_key, + connect_timeout=self.connect_timeout, + use_system_proxy=self.use_system_proxy, + ) + if self.should_cancel(): + return {"cancelled": True} + cmhub_models.cache_model_catalog(self.base_url, models) + return {"models": models} + + class ProductSuiteImportImagesWorker(BaseWorker): """Validate and copy local product images outside the GUI thread.""" @@ -2869,6 +2909,7 @@ class CMHubSettingsWorker(BaseWorker): self.api_key, connect_timeout=self.connect_timeout, ) + cmhub_models.cache_model_catalog(self.base_url, models) balance = None if self.include_balance: balance = ai.fetch_cmhub_balance( diff --git a/docs/04-architecture.md b/docs/04-architecture.md index e283371..cc5df15 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -77,12 +77,14 @@ imported → collected → generated → applied - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 - `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` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。 +- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。 **存储(同一事实只存一处)** - 应用配置(模型选择、生成参数、目录、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 为准。 - 业务数据(账号、任务、各阶段结果)→ 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` 仅保留兼容,⑥商品套图直接把卖点文本与结构化设置保存在项目表。 diff --git a/docs/api.md b/docs/api.md index b14bb56..646ecfe 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` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 可读取 `display_name/tags/recommended_for/tier/prices` 生成“默认档 / 高质量档 / 省点档”中文说明,但执行层只保存 cmhub alias。 +- `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` 时显示预计扣点;其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。 - `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。 @@ -461,6 +461,7 @@ class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图 class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载 class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台用本地原图调用图片理解,生成商品卖点与画面要求 +class CMHubModelCatalogWorker(BaseWorker) # 后台读取公开模型目录并填充进程内缓存,不提交图片、不扣点 class ProductSuiteHistoryExportWorker(BaseWorker) # ⑥ 后台复制一轮历史成功生成图 class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 diff --git a/docs/routes.md b/docs/routes.md index 65bef17..1dfe049 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -209,6 +209,7 @@ - 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张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 ## 流程导航 @@ -245,6 +246,6 @@ | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 | | `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | -| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成与AI帮写;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget | +| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget | > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 diff --git a/docs/tasks/T-650.md b/docs/tasks/T-650.md index 0afc150..d490aee 100644 --- a/docs/tasks/T-650.md +++ b/docs/tasks/T-650.md @@ -3,7 +3,7 @@ id: T-650 title: 商品套图AI帮写的付费前价格确认 phase: 7 deps: [T-647] -status: TODO +status: DONE created: 2026-07-17 --- @@ -77,3 +77,5 @@ git diff --check ## 执行记录 - 2026-07-17:根据AI帮写耗时和付费前确认需求创建任务。待实现。 +- 2026-07-17:开始实现共享模型目录缓存和AI帮写付费前确认。 +- 2026-07-17:已实现 `cmhub_models` 进程内模型目录缓存、设置页与商品套图共用缓存、后台目录读取 worker,以及默认取消的「开始AI帮写」付费确认。仅唯一无条件的图片理解价格显示预计扣点,实际扣点继续以网关响应 metadata 为准;关闭任务和应用会取消目录读取 worker。验证:`py -3.10 -m unittest discover -s tests`(574 项通过)、`py -3.10 -m ruff check app/cmhub_models.py app/gui/__init__.py app/gui/workers.py app/gui/tabs/product_suite.py tests/test_cmhub_models.py tests/test_workers.py tests/test_product_suite_gui.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 通过。 diff --git a/tests/test_cmhub_models.py b/tests/test_cmhub_models.py index f206099..a511874 100644 --- a/tests/test_cmhub_models.py +++ b/tests/test_cmhub_models.py @@ -8,6 +8,9 @@ from app import cmhub_models class CMHubModelDisplayTests(unittest.TestCase): + def tearDown(self): + cmhub_models.clear_model_catalog_cache() + def test_alias_label_prefers_display_name_tier_and_points(self): label = cmhub_models.alias_label( { @@ -44,6 +47,72 @@ class CMHubModelDisplayTests(unittest.TestCase): self.assertIn("生图别名 image-hd", summary) self.assertIn("扣点以返回结果为准", summary) + def test_catalog_price_only_accepts_one_unconditional_matching_vision_model(self): + models = [ + { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 2}], + } + ] + + cmhub_models.cache_model_catalog("https://CMHUB.example.com/", models, now=100) + cached = cmhub_models.cached_model_catalog( + "https://cmhub.example.com", + "vision-standard", + now=120, + ) + + self.assertEqual("2", cmhub_models.format_points_cost( + cmhub_models.unambiguous_points_cost( + cached, + "vision-standard", + "vision", + requires_image=True, + ) + )) + + def test_catalog_price_rejects_conditional_missing_or_ambiguous_prices(self): + base = { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + } + cases = [ + dict(base, prices=[{"resolution": "1K", "points_cost": 2}]), + dict(base, prices=[{"points_cost": 2}, {"points_cost": 3}]), + dict(base, pricing_status="unpriced", prices=[{"points_cost": 2}]), + dict(base, requires_image=False, prices=[{"points_cost": 2}]), + dict(base, prices=[{}]), + ] + for model in cases: + with self.subTest(model=model): + self.assertIsNone( + cmhub_models.unambiguous_points_cost( + [model], + "vision-standard", + "vision", + requires_image=True, + ) + ) + + cmhub_models.cache_model_catalog( + "https://cmhub.example.com", + [dict(base, prices=[{"points_cost": 2}])], + now=100, + ) + self.assertIsNone( + cmhub_models.cached_model_catalog( + "https://cmhub.example.com", + "vision-standard", + max_age_seconds=20, + now=121, + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 951f61b..68064a5 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -9,7 +9,15 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin -from app import accounts, appconfig, image_studio, image_studio_images, product_suite, prompts +from app import ( + accounts, + appconfig, + cmhub_models, + image_studio, + image_studio_images, + product_suite, + prompts, +) from app import gui if gui.QT_IMPORT_ERROR is not None: @@ -965,7 +973,14 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): _AiWriteWorker, ), mock.patch.object(tab, "_start_thread", return_value=object()), mock.patch.object( tab, "_status" - ) as status: + ) as status, mock.patch.object( + tab, + "_confirm_ai_write_request", + side_effect=lambda target, asset_ids, points_cost: tab._start_confirmed_ai_write( + target, + asset_ids, + ), + ): tab.start_ai_write() self.assertEqual( @@ -985,6 +1000,113 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_ai_write_uses_cached_price_before_confirming_the_first_eight_images(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = { + "cmhub": { + "base_url": "https://cmhub.example.com", + "vision_alias": "vision-standard", + } + } + appconfig.save_cmhub_config({"api_key": "test-key"}, path=config["cmhub_config_path"]) + project, assets = self._create_project_with_assets(temp_dir, config, 9) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + self.addCleanup(cmhub_models.clear_model_catalog_cache) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + cmhub_models.cache_model_catalog( + "https://cmhub.example.com", + [ + { + "alias": "vision-standard", + "operation_type": "vision", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"points_cost": 2}], + } + ], + ) + + with mock.patch.object(tab, "_confirm_ai_write_request") as confirm: + tab.start_ai_write() + + state_arg, asset_ids, points_cost = confirm.call_args.args + self.assertIs(state_arg, state) + self.assertEqual(tuple(asset.id for asset in assets[:8]), asset_ids) + self.assertEqual("2", cmhub_models.format_points_cost(points_cost)) + self.assertIsNone(state.ai_worker) + self.assertIsNone(state.ai_price_worker) + + self.assert_removed(temp_dir) + + def test_ai_write_cancelled_confirmation_does_not_start_worker(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 + tab._load_state(state) + + class _Button: + pass + + class _MessageBox: + Question = 1 + AcceptRole = 2 + RejectRole = 3 + + def __init__(self, *args): + self.start_button = _Button() + self.cancel_button = _Button() + self.clicked = self.cancel_button + + def setIcon(self, value): + pass + + def setWindowTitle(self, value): + pass + + def setText(self, value): + pass + + def addButton(self, text, role): + return self.start_button if role == self.AcceptRole else self.cancel_button + + def setDefaultButton(self, button): + pass + + def setEscapeButton(self, button): + pass + + def exec(self): + return 0 + + def clickedButton(self): + return self.clicked + + with mock.patch("app.gui.tabs.product_suite.QMessageBox", _MessageBox), mock.patch.object( + tab, + "_start_confirmed_ai_write", + ) as start_confirmed: + tab._confirm_ai_write_request(state, (assets[0].id,), None) + + start_confirmed.assert_not_called() + self.assertIsNone(state.ai_worker) + self.assertFalse(state.ai_confirmation_open) + + self.assert_removed(temp_dir) + def test_prompt_edit_expands_shrinks_and_reflows_without_internal_scrollbars(self): edit = AutoHeightPlainTextEdit() self.addCleanup(edit.close) @@ -2458,7 +2580,14 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): with mock.patch( "app.gui.tabs.product_suite.ProductSuiteAiWriteWorker", _AiWriteWorker, - ), mock.patch.object(tab, "_start_thread", return_value=object()): + ), mock.patch.object(tab, "_start_thread", return_value=object()), mock.patch.object( + tab, + "_confirm_ai_write_request", + side_effect=lambda target, asset_ids, points_cost: tab._start_confirmed_ai_write( + target, + asset_ids, + ), + ): tab.start_ai_write() self.assertIn("未绑定商品", captured["context"]) self.assertNotIn("draft_", captured["context"]) diff --git a/tests/test_workers.py b/tests/test_workers.py index e0d8aba..5d1712f 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -8,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from _helpers import REPO_ROOT # noqa: F401 -from app import db, image_studio, image_studio_images, workers +from app import cmhub_models, db, image_studio, image_studio_images, workers if workers.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") @@ -18,6 +18,7 @@ from PySide6.QtWidgets import QApplication from app.workers import BaseWorker, run_worker from app.gui.workers import ( + CMHubModelCatalogWorker, ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, ProductSuiteAiWriteWorker, @@ -230,6 +231,35 @@ class WorkerTests(unittest.TestCase): gen_title.assert_not_called() self.assertEqual(expected, result) + def test_cmhub_model_catalog_worker_caches_models_without_exposing_key(self): + cmhub_models.clear_model_catalog_cache() + self.addCleanup(cmhub_models.clear_model_catalog_cache) + worker = CMHubModelCatalogWorker( + "https://cmhub.example.com/", + "sk-cmhub-secret", + connect_timeout=7, + use_system_proxy=True, + ) + models = [{"alias": "vision-standard", "operation_type": "vision"}] + + with mock.patch("app.gui.workers.ai.fetch_cmhub_models", return_value=models) as fetch: + result = worker.execute() + + fetch.assert_called_once_with( + "https://cmhub.example.com", + "sk-cmhub-secret", + connect_timeout=7, + use_system_proxy=True, + ) + self.assertEqual(models, result["models"]) + self.assertEqual( + models, + cmhub_models.cached_model_catalog( + "https://cmhub.example.com", + "vision-standard", + ), + ) + def test_product_suite_history_export_worker_uses_round_export_service(self): worker = ProductSuiteHistoryExportWorker( 7,