feat(suite): confirm AI writing cost before request

This commit is contained in:
chengma
2026-07-17 11:48:25 +08:00
parent a06a02ddcf
commit 1ef5721a20
11 changed files with 620 additions and 18 deletions
+153
View File
@@ -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"}:
+1
View File
@@ -20,6 +20,7 @@ if QT_IMPORT_ERROR is None:
from .workers import (
AccountLoginCheckWorker,
AIModelTestWorker,
CMHubModelCatalogWorker,
CMHubSettingsWorker,
ApplyWorker,
CollectWorker,
+183 -10
View File
@@ -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()):
+42 -1
View File
@@ -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(