feat(suite): confirm AI writing cost before request
This commit is contained in:
@@ -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"}:
|
||||
|
||||
@@ -20,6 +20,7 @@ if QT_IMPORT_ERROR is None:
|
||||
from .workers import (
|
||||
AccountLoginCheckWorker,
|
||||
AIModelTestWorker,
|
||||
CMHubModelCatalogWorker,
|
||||
CMHubSettingsWorker,
|
||||
ApplyWorker,
|
||||
CollectWorker,
|
||||
|
||||
+183
-10
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user