From 6a0cd1c763af6549061f0c206349411c55dc9549 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 16:29:28 +0800 Subject: [PATCH] feat(settings): support gateway source switching --- app/ai.py | 129 +++++++++++++++++++++- app/gui/main_window.py | 11 ++ app/gui/tabs/generate.py | 35 +++++- app/gui/tabs/product_suite.py | 151 +++++++++++++++++++++++++- app/gui/tabs/settings.py | 113 +++++++++++++++---- app/gui/workers.py | 44 +++++++- app/image_studio_generation.py | 42 ++++++- docs/api.md | 10 +- docs/cmhub-integration-design.md | 1 + docs/routes.md | 18 +-- docs/tasks/T-677.md | 7 +- docs/ui/README.md | 1 + docs/ui/tab5-settings-T677.svg | 21 ++++ tests/test_ai.py | 41 +++++++ tests/test_gui.py | 51 +++++++-- tests/test_image_studio_generation.py | 98 +++++++++++++++++ tests/test_product_suite_gui.py | 35 ++++++ tests/test_workers.py | 15 ++- 18 files changed, 746 insertions(+), 77 deletions(-) create mode 100644 docs/ui/tab5-settings-T677.svg diff --git a/app/ai.py b/app/ai.py index d1b2187..daaf899 100644 --- a/app/ai.py +++ b/app/ai.py @@ -64,6 +64,88 @@ CMHUB_HTTP_POOL_SIZE = 32 _CMHUB_SESSION = None _CMHUB_SESSION_LOCK = threading.Lock() +_RUNTIME_AI_SNAPSHOT_KEY = "_cmshopee_ai_runtime" + + +def freeze_runtime_config( + config=None, + *, + cmhub_config_path=appconfig.CMHUB_CONFIG_PATH, + models_path=appconfig.AI_MODELS_PATH, + include_cmhub=None, + include_direct_models=None, +): + """Return an in-memory AI configuration snapshot for one worker run. + + Application config does not contain the default gateway key and direct model + definitions are stored in a separate file. Copy only the values used by the + worker into its private config so saving settings cannot change its endpoint, + model or credential. Callers must never persist this result. + """ + + source = appconfig.load_config() if config is None else config + configured_cmhub_path = source.get("cmhub_config_path") + configured_models_path = source.get("ai_models_path") + if configured_cmhub_path and ( + not cmhub_config_path or cmhub_config_path == appconfig.CMHUB_CONFIG_PATH + ): + cmhub_config_path = configured_cmhub_path + if configured_models_path and ( + not models_path or models_path == appconfig.AI_MODELS_PATH + ): + models_path = configured_models_path + cmhub_config_path = cmhub_config_path or appconfig.cmhub_config_file_path(source) + models_path = models_path or appconfig.ai_models_config_path(source) + snapshot = copy.deepcopy(source) + backend = appconfig.ai_backend(source) + if include_cmhub is None: + include_cmhub = backend == "cmhub" + if include_direct_models is None: + include_direct_models = backend == "direct" + runtime = {} + if include_cmhub: + runtime["cmhub_api_key"] = appconfig.get_cmhub_api_key(path=cmhub_config_path) + if include_direct_models: + runtime["direct_models"] = appconfig.list_ai_models( + path=models_path, + reveal_api_key=True, + ) + snapshot[_RUNTIME_AI_SNAPSHOT_KEY] = runtime + return snapshot + + +def validate_direct_generation_config( + config, + generate_mode, + *, + models_path=appconfig.AI_MODELS_PATH, +): + """Fail before a direct batch starts when its selected model is unusable.""" + + cfg = appconfig.load_config() if config is None else config + ai_cfg = appconfig.ai_config(cfg) + if _ai_backend(ai_cfg) != "direct": + return + mode = appconfig.normalize_generate_mode(generate_mode) + required = [] + if appconfig.generate_mode_includes_title(mode): + required.append(("text", "标题")) + if appconfig.generate_mode_includes_cover(mode): + required.append(("image", "封面")) + models = _runtime_direct_models(cfg) + errors = [] + for category, label in required: + try: + _role_model( + category, + ai_cfg.get("default_%s_model" % category), + models_path, + models=models, + ) + except Exception as exc: + errors.append("%s模型%s" % (label, str(exc))) + if errors: + raise AIError(";".join(errors)) def _cmhub_session(): @@ -134,7 +216,12 @@ def gen_title( on_event=on_event, ) _notify_step(on_step, "load_text_model") - model = _role_model("text", ai_cfg.get("default_text_model"), models_path) + model = _role_model( + "text", + ai_cfg.get("default_text_model"), + models_path, + models=_runtime_direct_models(cfg), + ) _notify_step(on_step, "title_build_request") payload = _chat_payload( model, @@ -248,7 +335,12 @@ def gen_cover( on_event=on_event, ) _notify_step(on_step, "load_image_model") - model = _role_model("image", ai_cfg.get("default_image_model"), models_path) + model = _role_model( + "image", + ai_cfg.get("default_image_model"), + models_path, + models=_runtime_direct_models(cfg), + ) resolution = str(resolution or ai_cfg.get("resolution", "1k")) quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90)) attempts = _attempt_count(ai_cfg, retry) @@ -1435,7 +1527,11 @@ def _vision_cmhub_error(exc): def _cmhub_runtime(config, operation, cmhub_config_path): hub = appconfig.cmhub_config(config) - api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path) + runtime = _runtime_ai_snapshot(config) + if runtime is not None and "cmhub_api_key" in runtime: + api_key = str(runtime.get("cmhub_api_key") or "") + else: + api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path) operation_config = { "title": ("title_alias", "生文别名"), "image": ("image_alias", "生图别名"), @@ -2171,10 +2267,33 @@ def _assert_public_ip(value): def _redact_cmhub(text, api_key): return appconfig.redact_secrets(text, [api_key]) -def _role_model(category, name, models_path): +def _runtime_ai_snapshot(config): + if not isinstance(config, dict): + return None + runtime = config.get(_RUNTIME_AI_SNAPSHOT_KEY) + return runtime if isinstance(runtime, dict) else None + + +def _runtime_direct_models(config): + runtime = _runtime_ai_snapshot(config) + if runtime is None or "direct_models" not in runtime: + return None + models = runtime.get("direct_models") + return copy.deepcopy(models) if isinstance(models, list) else [] + + +def _role_model(category, name, models_path, *, models=None): if not name: raise AIError("未配置默认 %s 模型" % category) - model = appconfig.get_model(name, path=models_path) + if models is None: + model = appconfig.get_model(name, path=models_path) + else: + model = next( + (dict(item) for item in models if str(item.get("name") or "") == str(name)), + None, + ) + if model is None: + raise AIError("AI 模型不存在: %s" % name) if model.get("category") != category: raise AIError("模型 %s 不是 %s 类别" % (name, category)) if not model.get("enabled", True): diff --git a/app/gui/main_window.py b/app/gui/main_window.py index ac98717..6a37062 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -109,6 +109,9 @@ class MainWindow(QMainWindow): self.tabs.currentChanged.connect(self._on_tab_changed) for title in TAB_TITLES: self.tabs.addTab(self._build_tab(title), title) + settings_tab = self._settings_tab() + if hasattr(settings_tab, "settingsSaved"): + settings_tab.settingsSaved.connect(self._on_settings_saved) self.setCentralWidget(self.tabs) self.show_status("就绪", level="muted") if startup_status: @@ -182,6 +185,14 @@ class MainWindow(QMainWindow): if hasattr(widget, "refresh_tasks"): widget.refresh_tasks() + def _on_settings_saved(self, backend): + for index in range(self.tabs.count()): + widget = self.tabs.widget(index) + if hasattr(widget, "refresh_gateway_state"): + widget.refresh_gateway_state() + label = "自定义网关" if str(backend) == "direct" else "默认网关" + self.show_status("设置已保存,当前使用%s" % label, level="success") + def _on_tab_changed(self, index): if self._reverting_tab_change: self._last_tab_index = index diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 87b0307..19bc6a5 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -1089,7 +1089,7 @@ class GenerateTab(QWidget): self.failed_progress_label = QLabel("失败 0") self.failed_progress_label.setObjectName("generateFailedProgressLabel") self.failed_progress_label.setVisible(False) - self.cmhub_balance_label = QLabel("cmhub余额:未获取") + self.cmhub_balance_label = QLabel("默认网关余额:未获取") self.cmhub_balance_label.setObjectName("generateCmhubBalanceLabel") self.cmhub_balance_label.setVisible(False) self.title_elapsed_label = QLabel("生标题用时 0 秒") @@ -1760,6 +1760,21 @@ class GenerateTab(QWidget): if not self._save_generate_mode_setting(show_status=False): return generate_mode = self._current_generate_mode() + if not self._is_cmhub_backend(): + try: + ai.validate_direct_generation_config( + self.config, + generate_mode, + models_path=( + self.config.get("ai_models_path") + or appconfig.ai_models_config_path(self.config) + ), + ) + except Exception as exc: + message = "自定义网关配置不完整:%s。请到⑤设置补齐本轮所需模型的地址、模型ID、API Key、接口类型和启用状态。" % str(exc) + QMessageBox.warning(self, "无法开始生成", message) + self._set_status("自定义网关配置不完整,请到⑤设置补齐", "warning") + return generate_cover = appconfig.generate_mode_includes_cover(generate_mode) base_candidates = self._generation_candidates(generate_mode) if not base_candidates: @@ -1837,7 +1852,12 @@ class GenerateTab(QWidget): "generate_mode": generate_mode, } ) - self._set_status(f"开始 AI 生成:{len(tasks)} 条") + if self._is_cmhub_backend(): + self._set_status(f"开始 AI 生成:{len(tasks)} 条") + else: + self._set_status( + "开始 AI 生成:%d 条;自定义网关(不计点数,费用由服务商收取)" % len(tasks) + ) thread.start() def _generation_candidates(self, generate_mode): @@ -2282,7 +2302,7 @@ class GenerateTab(QWidget): def _reset_cmhub_balance_label(self): self.cmhub_balance_label.setVisible(False) if self._is_cmhub_backend(): - self.cmhub_balance_label.setText("cmhub余额:生成后刷新") + self.cmhub_balance_label.setText("默认网关余额:生成后刷新") else: self.cmhub_balance_label.setText("") @@ -2293,9 +2313,14 @@ class GenerateTab(QWidget): balance = payload.get("points_balance") if isinstance(payload, dict) else None if balance is None: if not self.cmhub_balance_label.text(): - self.cmhub_balance_label.setText("cmhub余额:未获取") + self.cmhub_balance_label.setText("默认网关余额:未获取") return - self.cmhub_balance_label.setText(f"cmhub余额:{balance}") + self.cmhub_balance_label.setText(f"默认网关余额:{balance}") + + def refresh_gateway_state(self): + """Clear source-specific feedback after ⑤ saves a new gateway source.""" + + self._reset_cmhub_balance_label() def _is_cmhub_backend(self): try: diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 39134a8..a64a77b 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -81,6 +81,7 @@ from ..workers import ( CMHubModelCatalogWorker, ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, + ImageStudioResumeJobsWorker, ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, ProductSuiteHistoryExportWorker, @@ -723,13 +724,20 @@ class SuiteResultCard(QFrame): status_label.setWordWrap(False) status_label.setToolTip(status_text) footer.addWidget(status_label, 1) + self.retry_button = None if status in {"failed", "expired", "cancelled"}: - retry_button = QPushButton("重试") - retry_button.setMinimumWidth(52) - retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job)) - footer.addWidget(retry_button) + self.retry_button = QPushButton("重试") + self.retry_button.setMinimumWidth(52) + self.retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job)) + footer.addWidget(self.retry_button) layout.addLayout(footer) + def set_retry_enabled(self, enabled, tooltip=""): + if self.retry_button is None: + return + self.retry_button.setEnabled(bool(enabled)) + self.retry_button.setToolTip(str(tooltip or "")) + class SuiteHistoryImageCard(QFrame): """Read-only image card used by the product-suite history dialog.""" @@ -1884,6 +1892,8 @@ class SuiteTaskState: generation_price_worker: object = None generation_price_thread: object = None generation_confirmation_open: bool = False + resume_worker: object = None + resume_thread: object = None download_queue: list = field(default_factory=list) downloads: dict = field(default_factory=dict) download_tokens: dict = field(default_factory=dict) @@ -1962,6 +1972,10 @@ class ProductSuiteTab(QWidget): if self._prompt_template_init_error: self._status(self._prompt_template_init_error, "danger") + def showEvent(self, event): + super().showEvent(event) + self.refresh_gateway_state() + def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(10, 8, 10, 8) @@ -2111,6 +2125,11 @@ class ProductSuiteTab(QWidget): "QPushButton:hover { background: #245fce; }" ) layout.addWidget(self.generate_button) + self.resume_submitted_button = QPushButton("继续查询已提交图片") + self.resume_submitted_button.setObjectName("suiteResumeSubmittedButton") + self.resume_submitted_button.setToolTip("继续查询已提交到默认网关的图片,不会重新生成或再次扣点") + self.resume_submitted_button.setVisible(False) + layout.addWidget(self.resume_submitted_button) self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素") self.generate_helper_label.setWordWrap(True) self.generate_helper_label.setStyleSheet("color: #6b7280;") @@ -2396,6 +2415,7 @@ class ProductSuiteTab(QWidget): self.custom_category_edit.returnPressed.connect(self._commit_custom_category) self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit) self.generate_button.clicked.connect(self.toggle_generation) + self.resume_submitted_button.clicked.connect(self.resume_submitted_jobs) self.history_button.clicked.connect(self.open_history_dialog) self.undo_button.clicked.connect(self.undo_delete) self.more_button.clicked.connect(self._show_more_menu) @@ -4155,7 +4175,105 @@ class ProductSuiteTab(QWidget): if not state.generation_running(): self.generate_button.setText("生成套图(%d)" % total) + def _is_default_gateway(self): + try: + return appconfig.ai_backend(self.config) == "cmhub" + except Exception: + return False + + @staticmethod + def _is_default_gateway_job(job): + return ( + str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub" + and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub" + and bool(str(getattr(job, "task_id", "") or "").strip()) + ) + + def _resumable_default_gateway_jobs(self, state): + if state is None or state.project_id is None: + return [] + try: + jobs = image_studio.list_resumable_jobs( + project_id=state.project_id, + include_failed_downloads=True, + path=self.db_path, + ) + except Exception: + return [] + return [job for job in jobs if self._is_default_gateway_job(job)] + + def refresh_gateway_state(self): + state = self._displayed_state + if state is not None: + self._apply_running_state(state) + self._refresh_results(state) + + def _update_resume_submitted_action(self, state): + jobs = self._resumable_default_gateway_jobs(state) + self.resume_submitted_button.setVisible(bool(jobs)) + self.resume_submitted_button.setEnabled( + bool(jobs) + and state.resume_worker is None + and not state.generation_running() + and state.generation_price_worker is None + ) + + def resume_submitted_jobs(self, checked=False): + state = self._displayed_state + if state is None or state.project_id is None: + return + if state.resume_worker is not None: + self._status("正在继续查询已提交图片", "info") + return + jobs = self._resumable_default_gateway_jobs(state) + if not jobs: + self._status("当前没有可继续查询的默认网关图片", "info") + self._update_resume_submitted_action(state) + return + worker = ImageStudioResumeJobsWorker( + project_id=state.project_id, + aspect_ratio=state.settings.get("ratio", "1:1"), + db_path=self.db_path, + config=self.config, + cmhub_config_path=self.cmhub_config_path, + ) + state.resume_worker = worker + worker.finished.connect( + lambda result, state=state: self._on_resume_submitted_finished(state, result) + ) + worker.cancelled.connect( + lambda result, state=state: self._on_resume_submitted_finished(state, result) + ) + state.resume_thread = self._start_thread(worker, "商品套图继续查询") + self._apply_running_state(state) + self._status("开始继续查询%d张已提交图片,不会重新生成或再次扣点" % len(jobs), "info") + + def _on_resume_submitted_finished(self, state, result): + state.resume_worker = None + state.resume_thread = None + self._refresh_results(state) + self._update_resume_submitted_action(state) + if result.get("ok") is False: + error = str(result.get("error") or "") + if "cmhub_not_configured" in error or "缺少" in error: + self._message( + "继续查询失败", + "默认网关配置不可用,请恢复原默认网关配置后继续查询已提交图片。", + ) + else: + self._message("继续查询失败", _user_error(error)) + return + self._status("已完成已提交图片查询,请查看生成结果", "success") + + def _require_default_gateway(self, action): + if self._is_default_gateway(): + return True + self._message("当前不可用", "%s仅支持默认网关,请到⑤设置切换后再使用。" % action) + return False + def start_ai_write(self, checked=False): + if not self._require_default_gateway("商品套图AI帮写"): + return state = self._displayed_state if state is None: return @@ -4482,6 +4600,8 @@ class ProductSuiteTab(QWidget): self.start_generation(state) def start_generation(self, state, specs=None, *, retry_job_id=None): + if not self._require_default_gateway("商品套图生成"): + return False if state.generation_running(): self._status("当前套图任务仍在生成", "warning") return False @@ -5414,6 +5534,7 @@ class ProductSuiteTab(QWidget): generation_running = state.generation_running() generation_price_pending = state.generation_price_worker is not None pull_running = state.pull_running() + default_gateway = self._is_default_gateway() self.pull_button.setText( "正在停止..." if pull_running and state.pull_stop_requested @@ -5439,7 +5560,9 @@ class ProductSuiteTab(QWidget): widget.setEnabled(not generation_running) for row in self.category_rows.values(): row.set_controls_enabled(not generation_running) - self.generate_button.setEnabled(True) + self.generate_button.setEnabled( + generation_running or generation_price_pending or default_gateway + ) if generation_running: self.generate_button.setText( "正在停止..." @@ -5465,8 +5588,17 @@ class ProductSuiteTab(QWidget): ) self._refresh_totals(state) 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_write_button.setEnabled( + default_gateway and not ai_running and not generation_running + ) + if not default_gateway and not ai_running: + self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关") + self.generate_button.setToolTip("商品套图生成仅支持默认网关") + else: + self.ai_write_button.setToolTip("") + self.generate_button.setToolTip("") self.ai_cancel_button.setVisible(ai_running) + self._update_resume_submitted_action(state) self._update_context_actions(state) def _refresh_elapsed(self): @@ -5541,6 +5673,8 @@ class ProductSuiteTab(QWidget): card.retryRequested.connect(self.retry_job) card.menuRequested.connect(self._show_job_menu) card.deleteRequested.connect(self.delete_job_asset) + if not self._is_default_gateway(): + card.set_retry_enabled(False, "商品套图重新生成仅支持默认网关") self.result_grid.addWidget(card, index // columns, index % columns) self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success)) self.undo_button.setVisible(bool(state.undo_records)) @@ -5611,6 +5745,8 @@ class ProductSuiteTab(QWidget): ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec() def retry_job(self, job): + if not self._require_default_gateway("商品套图重新生成"): + return state = self._displayed_state if state is None: return @@ -5643,6 +5779,9 @@ class ProductSuiteTab(QWidget): copy_action = menu.addAction("复制路径") folder_action = menu.addAction("打开文件夹") retry_action = menu.addAction("重新生成") + if not self._is_default_gateway(): + retry_action.setEnabled(False) + retry_action.setToolTip("商品套图重新生成仅支持默认网关") delete_action = menu.addAction("删除") action = menu.exec(global_position) asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 6dd1ff6..d094a3f 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -5,6 +5,8 @@ from __future__ import annotations from contextlib import contextmanager import os +from PySide6.QtCore import Signal + from ... import ai as ai_module from ... import chrome from ... import cmhub_models @@ -14,7 +16,7 @@ from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker PLAINTEXT_CMHUB_API_KEY_WARNING = ( - "cmhub API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用 cmhub 网关使用。" + "默认网关 API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用默认网关使用。" "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" ) @@ -29,7 +31,9 @@ def CMHubSettingsWorker(*args, **kwargs): class SettingsTab(QWidget): """Tab 5: AI model definitions stored in data/config/ai_models.json.""" - BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")] + settingsSaved = Signal(str) + + BACKEND_ITEMS = [("默认网关", "cmhub"), ("自定义网关", "direct")] CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")] RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"] @@ -77,13 +81,37 @@ class SettingsTab(QWidget): for label, value in self.BACKEND_ITEMS: self.backend_combo.addItem(label, value) self.backend_combo.setVisible(False) + self.gateway_default_button = QPushButton("默认网关") + self.gateway_default_button.setObjectName("gatewayDefaultButton") + self.gateway_default_button.setCheckable(True) + self.gateway_custom_button = QPushButton("自定义网关") + self.gateway_custom_button.setObjectName("gatewayCustomButton") + self.gateway_custom_button.setCheckable(True) + self.gateway_button_group = QButtonGroup(self) + self.gateway_button_group.setExclusive(True) + self.gateway_button_group.addButton(self.gateway_default_button) + self.gateway_button_group.addButton(self.gateway_custom_button) + self.gateway_selector = QWidget() + self.gateway_selector.setObjectName("gatewaySourceSelector") + gateway_selector_layout = QHBoxLayout(self.gateway_selector) + gateway_selector_layout.setContentsMargins(0, 0, 0, 0) + gateway_selector_layout.setSpacing(0) + gateway_selector_layout.addWidget(self.gateway_default_button) + gateway_selector_layout.addWidget(self.gateway_custom_button) + self.gateway_selector.setStyleSheet( + "QPushButton { min-width: 108px; padding: 6px 12px; border: 1px solid #b8c0ca; " + "background: #f6f8fa; color: #57606a; }" + "QPushButton:first-child { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }" + "QPushButton:last-child { border-left: 0; border-top-right-radius: 4px; border-bottom-right-radius: 4px; }" + "QPushButton:checked { background: #0969da; border-color: #0969da; color: white; font-weight: 600; }" + ) self.cmhub_base_url_edit = QLineEdit() self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit") self.cmhub_base_url_edit.setPlaceholderText("https://host(不要带 /api 或 /api/v1)") self.cmhub_api_key_edit = QLineEdit() self.cmhub_api_key_edit.setObjectName("cmhubApiKeyEdit") self.cmhub_api_key_edit.setEchoMode(QLineEdit.Password) - self.cmhub_api_key_edit.setPlaceholderText("从 cmhub 网页端复制 API Key") + self.cmhub_api_key_edit.setPlaceholderText("从默认网关网页端复制 API Key") self.cmhub_title_alias_combo = QComboBox() self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo") self.cmhub_image_alias_combo = QComboBox() @@ -103,10 +131,10 @@ class SettingsTab(QWidget): self.cmhub_result_label = QLabel("") self.cmhub_result_label.setObjectName("cmhubResultLabel") self.cmhub_result_label.setWordWrap(True) - self.cmhub_base_url_hint_label = QLabel("Base URL 只填网关根,如 https://host;不要带 /api 或 /api/v1。") + self.cmhub_base_url_hint_label = QLabel("Base URL 只填默认网关根,如 https://host;不要带 /api 或 /api/v1。") self.cmhub_base_url_hint_label.setObjectName("cmhubBaseUrlHintLabel") self.cmhub_base_url_hint_label.setWordWrap(True) - self.cmhub_key_hint_label = QLabel("API Key 仅在 cmhub 网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。") + self.cmhub_key_hint_label = QLabel("API Key 仅在默认网关网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。") self.cmhub_key_hint_label.setObjectName("cmhubKeyHintLabel") self.cmhub_key_hint_label.setWordWrap(True) @@ -235,7 +263,7 @@ class SettingsTab(QWidget): ("状态", self.enabled_checkbox), ("服务商名", self.name_edit), ("类别", self.category_combo), - ("api_type", self.api_type_combo), + ("接口类型", self.api_type_combo), ("模型ID", self.model_id_edit), ("连接超时(秒)", self.connect_timeout_spin), ("网址", self.url_edit, True), @@ -296,8 +324,15 @@ class SettingsTab(QWidget): model_detail_layout.addLayout(action_layout) model_detail_layout.addWidget(self.test_result_label) + self.direct_gateway_notice = QLabel("图片理解与商品套图仅支持默认网关。") + self.direct_gateway_notice.setObjectName("directGatewayNoticeLabel") + self.direct_gateway_notice.setStyleSheet("color: #6b7280;") self.direct_role_panel = QWidget() - self.direct_role_panel.setLayout(direct_role_form) + direct_role_layout = QVBoxLayout(self.direct_role_panel) + direct_role_layout.setContentsMargins(0, 0, 0, 0) + direct_role_layout.setSpacing(8) + direct_role_layout.addLayout(direct_role_form) + direct_role_layout.addWidget(self.direct_gateway_notice) cmhub_form = self._three_column_form( [ @@ -332,7 +367,7 @@ class SettingsTab(QWidget): self.settings_panel_layout = panel_layout panel_layout.setContentsMargins(13, 18, 13, 18) self.ai_model_section_title = self._section_title( - "cmhub 网关", + "生成网关", "settingsAiModelSectionTitle", ) self.model_detail_section_title = self._section_title( @@ -352,6 +387,8 @@ class SettingsTab(QWidget): "settingsInfrastructureSectionTitle", ) panel_layout.addWidget(self.ai_model_section_title) + panel_layout.addWidget(self.gateway_selector) + panel_layout.addSpacing(8) panel_layout.addWidget(self.model_picker_panel) panel_layout.addSpacing(14) panel_layout.addWidget(self.model_detail_section_title) @@ -394,7 +431,8 @@ class SettingsTab(QWidget): self.delete_model_button.clicked.connect(self.delete_model) self.save_model_button.clicked.connect(self.save_model) self.test_connection_button.clicked.connect(self.test_connection) - self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) + self.gateway_default_button.toggled.connect(self._on_gateway_source_toggled) + self.gateway_custom_button.toggled.connect(self._on_gateway_source_toggled) self.cmhub_refresh_button.clicked.connect(self.refresh_cmhub_models) self.cmhub_test_button.clicked.connect(self.test_cmhub_connection) self.resolution_combo.currentIndexChanged.connect( @@ -480,7 +518,6 @@ class SettingsTab(QWidget): self.chrome_path_edit, ) combos = ( - self.backend_combo, self.cmhub_title_alias_combo, self.cmhub_image_alias_combo, self.cmhub_vision_alias_combo, @@ -516,6 +553,9 @@ class SettingsTab(QWidget): for widget in checkboxes: widget.toggled.connect(self._mark_dirty) + self.gateway_default_button.toggled.connect(self._mark_dirty) + self.gateway_custom_button.toggled.connect(self._mark_dirty) + def _mark_dirty(self, *args): if self._suspend_dirty > 0: return @@ -576,13 +616,36 @@ class SettingsTab(QWidget): self._cmhub_auto_refresh_done = True self.refresh_cmhub_models() + def _selected_backend(self): + return "direct" if self.gateway_custom_button.isChecked() else "cmhub" + + def _set_selected_backend(self, backend): + value = "direct" if str(backend or "").strip().lower() == "direct" else "cmhub" + target = self.gateway_custom_button if value == "direct" else self.gateway_default_button + for button in (self.gateway_default_button, self.gateway_custom_button): + previous = button.blockSignals(True) + button.setChecked(button is target) + button.blockSignals(previous) + previous = self.backend_combo.blockSignals(True) + self._set_combo_by_data(self.backend_combo, value) + self.backend_combo.blockSignals(previous) + + def _on_gateway_source_toggled(self, checked): + if checked: + self._on_backend_changed() + def _on_backend_changed(self, index=None): + backend = self._selected_backend() + previous = self.backend_combo.blockSignals(True) + self._set_combo_by_data(self.backend_combo, backend) + self.backend_combo.blockSignals(previous) self.backend_combo.setVisible(False) - self.model_picker_panel.setVisible(False) - self.model_detail_section_title.setVisible(False) - self.model_detail_panel.setVisible(False) - self.direct_role_panel.setVisible(False) - self.cmhub_panel.setVisible(True) + is_direct = backend == "direct" + self.model_picker_panel.setVisible(is_direct) + self.model_detail_section_title.setVisible(is_direct) + self.model_detail_panel.setVisible(is_direct) + self.direct_role_panel.setVisible(is_direct) + self.cmhub_panel.setVisible(not is_direct) self._set_cmhub_running(self.cmhub_thread is not None) self._update_button_state() @@ -733,8 +796,10 @@ class SettingsTab(QWidget): self._replace_config(saved) self._populate_app_settings() self._set_dirty(False) - self._set_status("设置已保存") + backend_label = "自定义网关" if self._selected_backend() == "direct" else "默认网关" + self._set_status("设置已保存,当前使用%s" % backend_label) QMessageBox.information(self, "保存设置", "设置已保存") + self.settingsSaved.emit(self._selected_backend()) return True def _app_settings_values(self): @@ -748,7 +813,7 @@ class SettingsTab(QWidget): self._show_error("默认调试端口必须在调试端口范围内") return None ai_cfg = appconfig.ai_config(self.config) - backend = "cmhub" + backend = self._selected_backend() text_model = self.default_text_model_combo.currentData() or ai_cfg.get("default_text_model") image_model = self.default_image_model_combo.currentData() or ai_cfg.get("default_image_model") cmhub_cfg = self._cmhub_settings_values(backend) @@ -847,7 +912,7 @@ class SettingsTab(QWidget): return self._populate_role_model_combos() ai_cfg = appconfig.ai_config(self.config) - self._set_combo_by_data(self.backend_combo, "cmhub") + self._set_selected_backend(ai_cfg.get("backend", "cmhub")) cmhub_cfg = appconfig.cmhub_config(self.config) self.cmhub_base_url_edit.setText(appconfig.normalize_cmhub_base_url(cmhub_cfg.get("base_url", ""))) self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(path=self.cmhub_config_path) @@ -1092,7 +1157,7 @@ class SettingsTab(QWidget): def _start_cmhub_worker(self, include_balance): if self.cmhub_thread is not None: - self._set_status("cmhub 检测正在进行...") + self._set_status("默认网关检测正在进行...") return base_url = appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text()) if base_url != self.cmhub_base_url_edit.text().strip(): @@ -1104,7 +1169,7 @@ class SettingsTab(QWidget): if not api_key: missing.append("API Key") if missing: - self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing)) + self._show_error("默认网关配置不完整:缺少 " + "、".join(missing)) return worker = CMHubSettingsWorker( base_url, @@ -1121,7 +1186,7 @@ class SettingsTab(QWidget): self.cmhub_worker = worker self.cmhub_thread = thread self._set_cmhub_running(True) - message = "正在测试 cmhub 连接并查询余额..." if include_balance else "正在刷新 cmhub 别名..." + message = "正在测试默认网关连接并查询余额..." if include_balance else "正在刷新默认网关别名..." self.cmhub_result_label.setText(message) self._set_status(message) thread.start() @@ -1183,8 +1248,8 @@ class SettingsTab(QWidget): def _cmhub_success_subject(self, payload): account_name = self._cmhub_account_display_name(payload) if account_name: - return f"cmhub 账号「{account_name}」连接成功" - return "cmhub 连接成功" + return f"账号「{account_name}」连接默认网关成功" + return "默认网关连接成功" def _cmhub_account_display_name(self, payload): if not isinstance(payload, dict): @@ -1237,7 +1302,7 @@ class SettingsTab(QWidget): return "" def _on_cmhub_failed(self, _task_id, error): - message = f"cmhub 连接失败:{error}" + message = "默认网关连接失败:%s" % str(error or "连接失败").replace("cmhub", "默认网关") self.cmhub_result_label.setText(message) self._set_status(message) diff --git a/app/gui/workers.py b/app/gui/workers.py index 9add4a3..365fcb1 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -285,7 +285,13 @@ class ImageStudioGenerateJobsWorker(BaseWorker): self.job_type = str(job_type or "main") self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path - self.config = config + self.config = ai.freeze_runtime_config( + config, + cmhub_config_path=cmhub_config_path, + models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), + include_cmhub=True, + include_direct_models=False, + ) self.cmhub_config_path = cmhub_config_path self._done = 0 self._failed = 0 @@ -358,7 +364,13 @@ class ProductSuiteGenerateWorker(BaseWorker): self.generation_round_key = str(generation_round_key or "").strip() self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path - self.config = config + self.config = ai.freeze_runtime_config( + config, + cmhub_config_path=cmhub_config_path, + models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), + include_cmhub=True, + include_direct_models=False, + ) self.cmhub_config_path = cmhub_config_path self.job_ids = [] self._done = 0 @@ -366,6 +378,8 @@ class ProductSuiteGenerateWorker(BaseWorker): self._lock = threading.Lock() def execute(self): + if appconfig.ai_backend(self.config) != "cmhub": + raise ValueError("商品套图仅支持默认网关,请到⑤设置切换后再生成") total = len(self.job_specs) if total <= 0: raise ValueError("商品套图生成任务不能为空") @@ -474,12 +488,20 @@ class ProductSuiteAiWriteWorker(BaseWorker): 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.config = ai.freeze_runtime_config( + config, + cmhub_config_path=cmhub_config_path, + models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), + include_cmhub=True, + include_direct_models=False, + ) self.cmhub_config_path = cmhub_config_path def execute(self): if self.should_cancel(): return {"cancelled": True} + if appconfig.ai_backend(self.config) != "cmhub": + raise ValueError("商品套图AI帮写仅支持默认网关,请到⑤设置切换后再使用") result = ai.analyze_product_images( self.instruction, self.context, @@ -583,7 +605,13 @@ class ImageStudioResumeJobsWorker(BaseWorker): self.project_id = int(project_id) if project_id is not None else None self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path - self.config = config + self.config = ai.freeze_runtime_config( + config, + cmhub_config_path=cmhub_config_path, + models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), + include_cmhub=True, + include_direct_models=False, + ) self.cmhub_config_path = cmhub_config_path self._done = 0 self._failed = 0 @@ -689,7 +717,11 @@ class GenerateWorker(BaseWorker): self.tasks = list(tasks) self.prompt_values = dict(prompt_values or {}) self.db_path = db_path - self.config = config + self.config = ai.freeze_runtime_config( + config, + cmhub_config_path=(config or {}).get("cmhub_config_path", appconfig.CMHUB_CONFIG_PATH), + models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), + ) self.diagnostic_log_dir = diagnostic_log_dir self.generation_scope = product_status.normalize_scope(generation_scope) self.product_status_counts = { @@ -786,6 +818,8 @@ class GenerateWorker(BaseWorker): ), excluded=self.status_scope_excluded, ) + if appconfig.ai_backend(self.config) == "direct": + start_message += ";自定义网关(不计点数,费用由服务商收取)" self._log_run_event(start_message) try: summary = ai.generate_batch( diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 8f7067c..643655a 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -36,6 +36,18 @@ def _runtime(config, cmhub_config_path): return ai._cmhub_runtime(config, "image", cmhub_config_path) +def _is_default_gateway_job(job): + return ( + str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub" + and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub" + ) + + +def _ensure_new_submission_allowed(config): + if appconfig.ai_backend(config) != "cmhub": + raise ImageStudioGenerationError("商品套图仅支持默认网关,请到⑤设置切换后再生成") + + def _generated_kind(job_type): return "generated_detail" if str(job_type) == "detail" else "generated_main" @@ -85,8 +97,10 @@ def create_generation_jobs( count, *, job_type="main", + config=None, path=None, ): + _ensure_new_submission_allowed(config) total = max(0, int(count or 0)) if total <= 0: raise ImageStudioGenerationError("生成数量必须大于0") @@ -126,6 +140,7 @@ def generate_image_jobs( prompt, count, job_type=job_type, + config=config, path=path, ) return run_jobs( @@ -179,13 +194,35 @@ def run_jobs( job_list = list(jobs or []) if not job_list: return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []} + rejected = [ + job + for job in job_list + if getattr(job, "task_id", None) and not _is_default_gateway_job(job) + ] + job_list = [job for job in job_list if job not in rejected] + summary = { + "total": len(job_list) + len(rejected), + "success": 0, + "failed": len(rejected), + "cancelled": 0, + "jobs": [ + { + "job": job, + "status": "failed", + "error": "该已提交任务不属于默认网关,不能继续查询", + } + for job in rejected + ], + } + if not job_list: + return summary + runtime = _runtime(cfg, cmhub_config_path) ai_cfg = appconfig.ai_config(cfg) image_root = appconfig.image_dir(cfg) max_workers = min(MAX_CMHUB_IMAGE_STUDIO_WORKERS, max(1, int(ai_cfg.get("image_concurrency", 1) or 1)), len(job_list)) should_stop = should_stop or (lambda: False) lock = threading.Lock() - summary = {"total": len(job_list), "success": 0, "failed": 0, "cancelled": 0, "jobs": []} def record(result): with lock: @@ -373,8 +410,11 @@ def _submit_or_resume_job( reference_assets=(), ): if job.task_id: + if not _is_default_gateway_job(job): + raise ImageStudioGenerationError("该已提交任务不属于默认网关,不能继续查询") _notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id}) return _request_result(job.task_id, runtime, config) + _ensure_new_submission_allowed(config) _raise_if_stopped(should_stop) ai_cfg = appconfig.ai_config(config) resolution = str(ai_cfg.get("resolution", "1k") or "1k") diff --git a/docs/api.md b/docs/api.md index 7fe447c..e9a81b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -330,13 +330,13 @@ 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` 兼容路径。 +- `backend=direct`:设置页「自定义网关」的客户端直连兼容路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。②在创建 worker 前按本轮生成内容校验所需模型;运行中使用冻结的内存模型快照,不重读 `ai_models.json`。它不支持图片理解或商品套图新建任务。 +- `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` 返回别名清单,供设置页动态下拉、商品套图 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` 时显示预计扣点;正式套图同样只在当前 `image_alias` 命中唯一无条件的图片生图价格时显示单张和按最终 `specs` 计算的总价。其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。 - `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。 +- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;标题/图片并发会被夹到 1..5,失败重试次数会被夹到 0..10,兼容旧配置中的超限值;Key 本地明文存储,但不入日志、不导出。②/⑥ worker 启动时通过 `freeze_runtime_config()` 深拷贝来源、端点、别名/模型、超时、代理与独立配置文件中的 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 安全校验和下载。 - 调试 cmhub 图片 URL:默认不在日志或 DB 中记录完整 `image_url`。若本机调试需要查看,启动程序前设置环境变量 `CMSHOPEE_DEBUG_CMHUB_IMAGE_URL=1`,②本轮可见运行日志会显示脱敏后的“cmhub 图片 URL”,但该调试行不写入 SQLite `run_log_events`。 @@ -441,7 +441,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None, - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 -- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。 +- 默认网关托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到默认网关;界面不展示 Provider URL、OpenAI Key 或上游接口路径。`create_generation_jobs()` 与无 `task_id` 的提交分支均拒绝非默认网关;`run_jobs()` 只放行同时满足 `generation_source="cmhub"`、`provider="cmhub"` 与非空 `task_id` 的旧任务继续查询,其他来源不得借 task ID 触发轮询或新提交。 - T-658a 后商品套图异步提交的图片字段统一为 `images` 数组(每项仅含本地编码的 `image_base64`),单图不再保留顶层 `image_base64` 兼容字段。提交层限制最多8张、单图原文件10MiB、编码后总输入32MiB;参考图快照字段由 T-658b 扩展。 - T-658b 后 `image_studio.create_job(..., reference_asset_ids=...)` 接收同项目、去重且不包含主图的有序图片 ID 列表,并以 JSON 快照写入 `image_studio_jobs.reference_asset_ids`;`job_reference_asset_ids(job)` 负责解析和校验。历史 `NULL` 快照返回空列表,恢复/重试不根据当前商品原图补图。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 @@ -505,7 +505,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` 分生文/生图/图片理解并过滤未计价别名,其中图片理解额外要求 `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` 并回填控件。 +- `SettingsTab` 的生成网关配置:用 `gatewayDefaultButton` / `gatewayCustomButton` 分段选择器表达 `cmhub` / `direct` 来源,选择仅标记待保存,保存后才写 `ai.backend`。默认网关 Base URL 保存/刷新前规整为网关根,API Key 单独读写 `data/config/cmhub.json`,别名下拉来自 `fetch_cmhub_models()`;自定义网关复用 `data/config/ai_models.json` 的模型 CRUD 与文本/图片角色选择。两套配置并存,模型 CRUD 立即保存;`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存。`settingsSaved` 通知主窗口刷新⑥能力状态;`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于保存和放弃来源选择。 - 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 5cfd199..fb15fc6 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -14,6 +14,7 @@ > **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秒且读超时不自动重发,避免结果未确认时重复扣点。 +> **v3.8 修订(2026-07-20,T-677)**:T-529 的普通 UI 单来源收口有限放开为「默认网关 / 自定义网关」分段选择器,来源仅在保存设置后切换。自定义网关是客户端直连的过渡兼容路径,只覆盖②生文、生图;模型清单与默认网关配置并存。⑥商品套图和图片理解仍只允许默认网关新建请求,但此前已由默认网关接受、且同时带 `generation_source=cmhub`、`provider=cmhub` 与 `task_id` 的套图 job 可继续轮询、下载、保存,避免已扣点结果丢失。运行中的 worker 冻结来源、模型与密钥的内存快照,后续保存设置不影响本轮;密钥不写入 SQLite、日志或 UI。 ## 1. 背景与目标 diff --git a/docs/routes.md b/docs/routes.md index 4984c4c..fe29bca 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,7 +14,7 @@ | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;开始前确认“仅状态正常”或“所有状态”生成范围后,AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 | | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;新增账号不自动启动 Chrome,首次启动复用初始卖家中心页,重复操作复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | -| 设置 | cmhub 网关/API Key、生文/生图/图片理解别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | +| 设置 | 默认网关/API Key 或自定义网关模型配置、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | | 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,AI帮写理解图片内容,按套图分类异步生图,查看历史与重试 | 本地生成,中 | 旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。 @@ -171,14 +171,14 @@ ## 设置 - 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。 -- cmhub 网关配置(T-529 后普通用户唯一 AI 入口):设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。 - - T-532 需求:测试连接/查余额成功时,如果 cmhub `/balance` 返回账号名、用户名或邮箱等可识别信息,成功提示应显示 `cmhub 账号「<账号名>」连接成功:...`,让用户确认当前 API Key 属于哪个 cmhub 账号;当前接口结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,优先显示 `account.display_name`,没有时再用 `account.username` / `user` 等兜底;邮箱需脱敏,没有账号信息时保留 `cmhub 连接成功:...` 兜底文案。 - - API Key 存在 `data/config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示“默认档 / 高质量档 / 省点档”、展示名、单价/需原图提示;网关临时不可达时保留已存别名。 - - 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去设置补配置,不静默回退 direct。 +- 生成网关(T-677):用具备选中态的「默认网关 / 自定义网关」来源选择器替代历史「AI 后端」下拉。切换只改变待保存状态并立即切换面板,点击「保存设置」才写入 `ai.backend=cmhub/direct`;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。 + - 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。 + - 自定义网关复用既有模型列表、模型详情、默认文本模型、默认图像模型和模型连接测试;它仅兼容现有 direct 标题/封面请求,`chat/auto` 走聊天请求、`images_edits` 走图片编辑请求,不承诺自动探测或所有 OpenAI 兼容服务可用。说明行明确「图片理解与商品套图仅支持默认网关」。 + - 允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。 + - ⑥商品套图在自定义网关下禁用新建、重新生成和 AI帮写;但已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。若默认网关配置已不可用,提示恢复原默认网关配置。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。 - T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - - direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 -- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。 + - 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可维护并立即保存,切换回默认网关不会清除这些配置。 +- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。自定义模型角色选择只在自定义网关面板显示。 - 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;设置中的「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 900 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。 - 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。 @@ -218,7 +218,7 @@ - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - 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张已下载的本地图片,在一次请求中作为同商品的多角度/细节/包装/场景证据集联合理解,超过8张时状态提示只使用前8张,原图勾选不改变输入图片。返回一份可直接编辑的商品级卖点与套图画面要求,按商品概述、可确认卖点、人群与场景、套图画面要求、待确认或避免编造的信息组织,不按图1、图2逐图说明;图片有可见差异时明确为待确认项。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。切换到自定义网关后,只允许对已提交默认网关任务继续查询、下载和保存,不允许新建或重试。 - AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 - 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。 - 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 diff --git a/docs/tasks/T-677.md b/docs/tasks/T-677.md index c8ddea7..82f5369 100644 --- a/docs/tasks/T-677.md +++ b/docs/tasks/T-677.md @@ -3,7 +3,7 @@ id: T-677 title: ⑤生成网关来源切换(默认网关/自定义网关) phase: 7 deps: [T-517, T-529] -status: TODO +status: DONE created: 2026-07-20 --- @@ -147,4 +147,7 @@ git diff --check ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-20:⑤新增「默认网关 / 自定义网关」互斥来源选择器,选择仅标记未保存,保存后写入 `ai.backend`;默认网关与自定义模型配置并存,自定义模型 CRUD 继续立即保存。②在自定义来源下启动前校验本轮所需模型,运行日志和状态栏明确不计点数。 +- 2026-07-20:新增 AI 运行时快照,仅将本轮实际所需的默认网关 Key 或自定义模型复制到 worker 内存;保存设置不会改变运行中的任务,且默认网关商品套图 worker 不读取无关的自定义模型密钥。 +- 2026-07-20:⑥在自定义来源下禁用新建、重试和 AI帮写;增加「继续查询已提交图片」,仅恢复来源与 provider 都为 `cmhub` 且带 `task_id` 的既有任务,禁止新提交或二次扣点。同步更新 API/流程文档和设置页效果图。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(639 项);`py -3.10 -m ruff check app tests main.py`;`py -3.10 -m compileall app main.py`;`git diff --check`。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 3c1ba1d..1210430 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -11,6 +11,7 @@ | [tab3-update-shopee.svg](tab3-update-shopee.svg) | ③ 更新蝦皮:已生成任务、开始更新确认、执行结果、汇总 | | [tab4-accounts.svg](tab4-accounts.svg) | 账号管理:账号列表、启动登录、检测登录、账号编辑弹窗 | | [tab5-settings.svg](tab5-settings.svg) | 设置:AI 模型/Key、路径、端口 | +| [tab5-settings-T677.svg](tab5-settings-T677.svg) | 设置(T-677 已实现):生成网关来源选择器,默认网关与自定义网关的双面板切换、保存生效语义与商品套图限制说明 | | [tab6-image-studio.svg](tab6-image-studio.svg) | 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 | | [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 | | [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 | diff --git a/docs/ui/tab5-settings-T677.svg b/docs/ui/tab5-settings-T677.svg new file mode 100644 index 0000000..21419af --- /dev/null +++ b/docs/ui/tab5-settings-T677.svg @@ -0,0 +1,21 @@ + + + + 蝦皮圈優化助手 + + ① 导入采集② AI生成③ 更新蝦皮账号管理设置商品套图 + 生成网关选择后先预览配置,点击保存设置后才切换本次使用的来源 + 默认网关自定义网关 + + 默认网关配置Base URL 只填默认网关根,不要带 /api 或 /api/v1 + Base URLhttps://gateway.example.com + API Key•••••••••••••••• + 生文别名title-standard ▼ + 生图别名image-hd ▼ + 图片理解vision-standard ▼ + 刷新别名测试连接/查余额账号「主账号」连接默认网关成功 + + 自定义网关切换后显示模型列表、模型详情、默认文本模型和默认图像模型。 + 图片理解与商品套图仅支持默认网关;自定义网关不显示点数余额。 + 保存设置 + diff --git a/tests/test_ai.py b/tests/test_ai.py index 6348d54..f1fd628 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -106,6 +106,47 @@ class AITests(TempDirMixin, unittest.TestCase): appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=key_path) return cfg, key_path + def test_freeze_runtime_config_keeps_worker_credentials_and_models_in_memory(self): + with self.make_temp_dir() as temp_dir: + config = self._config() + config["ai"]["cmhub"] = { + "base_url": "https://cmhub.example.com", + "image_alias": "image-hd", + "connect_timeout": 3, + } + cmhub_path = os.path.join(temp_dir, "cmhub.json") + models_path = os.path.join(temp_dir, "ai_models.json") + self._write_models(models_path) + appconfig.save_cmhub_config({"api_key": "sk-before-save"}, path=cmhub_path) + + snapshot = ai.freeze_runtime_config( + config, + cmhub_config_path=cmhub_path, + models_path=models_path, + include_cmhub=True, + ) + appconfig.save_cmhub_config({"api_key": "sk-after-save"}, path=cmhub_path) + replacement_models = appconfig.list_ai_models( + path=models_path, + reveal_api_key=True, + ) + for model in replacement_models: + model["name"] = "已保存后替换的%s模型" % model["category"] + appconfig.save_ai_models_config({"models": replacement_models}, path=models_path) + + self.assertEqual( + "sk-before-save", + ai._cmhub_runtime(snapshot, "image", cmhub_path)["api_key"], + ) + ai.validate_direct_generation_config( + snapshot, + "title", + models_path=models_path, + ) + self.assertIn("direct_models", snapshot["_cmshopee_ai_runtime"]) + + self.assert_removed(temp_dir) + def _collected_tasks(self, temp_dir, cfg, titles=None): titles = titles or ["旧标题A", "旧标题B"] db.init_db(cfg["db_path"]) diff --git a/tests/test_gui.py b/tests/test_gui.py index 33ae5e5..d90e7e1 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2970,6 +2970,39 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_settings_gateway_selector_switches_panels_and_persists_only_on_save(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.default_config()["ai"] + cfg["ai"]["backend"] = "cmhub" + tab = SettingsTab( + config=cfg, + config_path=cfg["config_path"], + ai_models_path=cfg["ai_models_path"], + ) + self.addCleanup(tab.close) + + self.assertTrue(tab.gateway_default_button.isChecked()) + self.assertFalse(tab.gateway_custom_button.isChecked()) + self.assertFalse(tab.cmhub_panel.isHidden()) + self.assertTrue(tab.model_picker_panel.isHidden()) + + tab.gateway_custom_button.setChecked(True) + self.assertTrue(tab.gateway_custom_button.isChecked()) + self.assertFalse(tab.gateway_default_button.isChecked()) + self.assertTrue(tab.cmhub_panel.isHidden()) + self.assertFalse(tab.model_picker_panel.isHidden()) + self.assertFalse(tab.direct_role_panel.isHidden()) + self.assertTrue(tab.is_dirty()) + self.assertEqual("cmhub", appconfig.load_config(cfg["config_path"])["ai"]["backend"]) + + with mock.patch("app.gui.QMessageBox.information"): + self.assertTrue(tab.save_app_settings()) + + self.assertEqual("direct", appconfig.load_config(cfg["config_path"])["ai"]["backend"]) + + self.assert_removed(temp_dir) + def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -3103,8 +3136,8 @@ class GuiTests(TempDirMixin, unittest.TestCase): "points_balance": 66, } ) - self.assertIn("cmhub 账号「主账号」连接成功", tab.cmhub_result_label.text()) - self.assertNotIn("cmhub 账号「cmhub_user」", tab.cmhub_result_label.text()) + self.assertIn("账号「主账号」连接默认网关成功", tab.cmhub_result_label.text()) + self.assertNotIn("账号「cmhub_user」连接默认网关成功", tab.cmhub_result_label.text()) self.assertIn("余额 66", tab.cmhub_result_label.text()) self.assertEqual(tab.cmhub_result_label.text(), statuses[-1]) @@ -3116,7 +3149,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "points_balance": 67, } ) - self.assertIn("cmhub 账号「备用账号」连接成功", tab.cmhub_result_label.text()) + self.assertIn("账号「备用账号」连接默认网关成功", tab.cmhub_result_label.text()) tab._on_cmhub_finished( { @@ -3126,12 +3159,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): "points_balance": 77, } ) - self.assertIn("cmhub 账号「o***r@example.com」连接成功", tab.cmhub_result_label.text()) + self.assertIn("账号「o***r@example.com」连接默认网关成功", tab.cmhub_result_label.text()) self.assertNotIn("owner@example.com", tab.cmhub_result_label.text()) tab._on_cmhub_finished({"ok": True, "models": models, "points_balance": 88}) - self.assertIn("cmhub 连接成功", tab.cmhub_result_label.text()) - self.assertNotIn("cmhub 账号", tab.cmhub_result_label.text()) + self.assertIn("默认网关连接成功", tab.cmhub_result_label.text()) + self.assertNotIn("账号「", tab.cmhub_result_label.text()) self.assert_removed(temp_dir) def test_settings_tab_tracks_dirty_state_and_programmatic_cmhub_refresh_is_clean(self): @@ -3381,7 +3414,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) self.assertEqual(tab.title_elapsed_label.width(), tab.cover_elapsed_label.width()) self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName()) - self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) + self.assertEqual("默认网关余额:未获取", tab.cmhub_balance_label.text()) self.assertTrue(tab.cmhub_balance_label.isHidden()) self.assertEqual(0, tab.title_progress_bar.value()) self.assertEqual(0, tab.cover_progress_bar.value()) @@ -3993,7 +4026,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.addCleanup(tab.close) self.assertTrue(tab.cmhub_balance_label.isHidden()) - self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) + self.assertEqual("默认网关余额:未获取", tab.cmhub_balance_label.text()) tab._on_generate_progress( { @@ -4006,7 +4039,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "points_balance": 88, } ) - self.assertEqual("cmhub余额:88", tab.cmhub_balance_label.text()) + self.assertEqual("默认网关余额:88", tab.cmhub_balance_label.text()) self.assertTrue(tab.cmhub_balance_label.isHidden()) with mock.patch("app.gui.QMessageBox.warning") as warning: diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index a59c90e..b0c684b 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -149,6 +149,104 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_direct_gateway_rejects_new_suite_job_before_creation(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + cfg["ai"]["backend"] = "direct" + + with mock.patch("app.image_studio_generation.image_studio.create_job") as create_job: + with self.assertRaises(image_studio_generation.ImageStudioGenerationError): + image_studio_generation.create_generation_jobs( + project.id, + source.id, + "不应提交", + 1, + config=cfg, + path=cfg["db_path"], + ) + + create_job.assert_not_called() + + self.assert_removed(temp_dir) + + def test_resume_rejects_non_default_gateway_task_without_reading_gateway_config(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + cfg["ai"]["backend"] = "direct" + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="旧任务", + generation_source="direct", + provider="direct", + path=cfg["db_path"], + ) + job = image_studio.set_job_submitted( + job.id, + "custom-task-1", + path=cfg["db_path"], + ) + + with mock.patch("app.image_studio_generation._runtime") as runtime: + summary = image_studio_generation.run_jobs( + [job], + config=cfg, + path=cfg["db_path"], + ) + + runtime.assert_not_called() + self.assertEqual(1, summary["total"]) + self.assertEqual(1, summary["failed"]) + self.assertIn("不属于默认网关", summary["jobs"][0]["error"]) + + self.assert_removed(temp_dir) + + def test_direct_selection_still_resumes_submitted_default_gateway_task(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + cfg["ai"]["backend"] = "direct" + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + prompt="已扣点图片", + generation_source="cmhub", + provider="cmhub", + path=cfg["db_path"], + ) + job = image_studio.set_job_submitted( + job.id, + "cmhub-task-1", + path=cfg["db_path"], + ) + + with mock.patch( + "app.image_studio_generation._runtime", + return_value=self._runtime(), + ), mock.patch( + "app.image_studio_generation.ai._cmhub_call_once", + return_value={ + "task_id": "cmhub-task-1", + "status": "succeeded", + "result": {"image_url": "https://cdn.example.com/result.png"}, + }, + ) as poll, mock.patch( + "app.image_studio_generation.ai._download_cmhub_image_with_retry", + return_value=(self._png_bytes(), 0.1), + ): + summary = image_studio_generation.run_jobs( + [job], + config=cfg, + path=cfg["db_path"], + ) + + poll.assert_called_once() + self.assertEqual(1, summary["success"]) + self.assertEqual("succeeded", image_studio.get_job(job.id, path=cfg["db_path"]).status) + + self.assert_removed(temp_dir) + def test_generate_image_jobs_sends_selected_aspect_ratio(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 04f917d..15be956 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -153,6 +153,41 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): path=db_path, ) + def test_direct_gateway_blocks_new_suite_actions_but_keeps_default_job_resume_available(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + config["ai"] = appconfig.ai_config(config) + config["ai"]["backend"] = "direct" + project, assets = self._create_project_with_assets(temp_dir, config, 1) + job = image_studio.create_job( + project.id, + source_asset_id=assets[0].id, + job_type="白底图", + prompt="已提交图片", + generation_source="cmhub", + provider="cmhub", + path=config["db_path"], + ) + image_studio.set_job_submitted(job.id, "cmhub-task-1", path=config["db_path"]) + + 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) + tab.refresh_gateway_state() + + self.assertFalse(tab.generate_button.isEnabled()) + self.assertFalse(tab.ai_write_button.isEnabled()) + self.assertFalse(tab.resume_submitted_button.isHidden()) + self.assertTrue(tab.resume_submitted_button.isEnabled()) + self.assertIn("仅支持默认网关", tab.generate_button.toolTip()) + + self.assert_removed(temp_dir) + def test_tab_builds_suite_controls_without_old_detail_workspace(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) diff --git a/tests/test_workers.py b/tests/test_workers.py index f9d1961..7110a3c 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -423,13 +423,16 @@ class WorkerTests(unittest.TestCase): ) 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", + analyze.assert_called_once() + args, kwargs = analyze.call_args + self.assertEqual( + ("补充要求", "输出语言:繁体中文", ["first.jpg", "second.jpg"]), + args, ) + self.assertEqual(worker.config, kwargs["config"]) + self.assertEqual("cmhub.json", kwargs["cmhub_config_path"]) + self.assertIn("cmhub_api_key", worker.config["_cmshopee_ai_runtime"]) + self.assertNotIn("direct_models", worker.config["_cmshopee_ai_runtime"]) gen_title.assert_not_called() self.assertEqual(expected, result)