feat(suite): confirm AI writing cost before request
This commit is contained in:
@@ -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