From eea3fdd0a9577598dadcf8e081a2f77a7cfbca74 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 16:18:11 +0800 Subject: [PATCH] feat(product-suite): add prompt template settings --- app/appconfig.py | 4 + app/default_prompts/product_suite/__init__.py | 1 + app/default_prompts/product_suite/base.txt | 10 + app/gui/__init__.py | 1 + app/gui/product_suite_prompt_dialog.py | 318 ++++++++++++++++++ app/gui/tabs/product_suite.py | 78 ++++- app/product_suite.py | 205 ++++++++++- app/prompts.py | 112 ++++++ docs/04-architecture.md | 4 +- docs/tasks/T-637.md | 6 +- tests/test_packaging.py | 3 + tests/test_product_suite.py | 79 ++++- tests/test_product_suite_gui.py | 151 ++++++++- tests/test_prompts.py | 80 +++++ 14 files changed, 1017 insertions(+), 35 deletions(-) create mode 100644 app/default_prompts/product_suite/__init__.py create mode 100644 app/default_prompts/product_suite/base.txt create mode 100644 app/gui/product_suite_prompt_dialog.py diff --git a/app/appconfig.py b/app/appconfig.py index 9ac9dcb..02c4135 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -265,6 +265,10 @@ def image_studio_prompts_dir(config=None) -> str: return data_path("prompts", "image_studio", config=config) +def product_suite_prompt_path(config=None) -> str: + return data_path("prompts", "product_suite", "base.txt", config=config) + + def diagnostic_log_dir(config=None) -> str: return data_path("logs", config=config) diff --git a/app/default_prompts/product_suite/__init__.py b/app/default_prompts/product_suite/__init__.py new file mode 100644 index 0000000..8b737b6 --- /dev/null +++ b/app/default_prompts/product_suite/__init__.py @@ -0,0 +1 @@ +"""Bundled product-suite prompt resources.""" diff --git a/app/default_prompts/product_suite/base.txt b/app/default_prompts/product_suite/base.txt new file mode 100644 index 0000000..140de3a --- /dev/null +++ b/app/default_prompts/product_suite/base.txt @@ -0,0 +1,10 @@ +套图名称:{套图名称}{补充描述} +平台:{平台};国家地区:{国家地区};输出语言:{输出语言}。 +{尺寸与长图规则} +{禁用内容规则} +{价格信息规则} +{尺码信息规则} +{参考图规则} +商品卖点与要求: +{商品卖点与要求} +本次生成比例:{图片比例}。请严格按此比例输出,不能改成其他长宽比。 diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 37ac343..a74fcca 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -39,6 +39,7 @@ if QT_IMPORT_ERROR is None: from .tabs.collect import CollectTab from .tabs.generate import GenerateTab from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab + from .product_suite_prompt_dialog import ProductSuitePromptDialog from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab from .tabs.settings import SettingsTab from .main_window import MainWindow diff --git a/app/gui/product_suite_prompt_dialog.py b/app/gui/product_suite_prompt_dialog.py new file mode 100644 index 0000000..0238285 --- /dev/null +++ b/app/gui/product_suite_prompt_dialog.py @@ -0,0 +1,318 @@ +"""Product-suite prompt template editor and final prompt preview.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QMenu, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QSplitter, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .. import product_suite, prompts + + +class ProductSuitePromptDialog(QDialog): + """Edit one global product-suite template and preview the final request.""" + + def __init__( + self, + *, + prompt_path, + base_prompt, + settings, + item_id, + parent=None, + ): + super().__init__(parent) + self.setObjectName("productSuitePromptDialog") + self.setWindowTitle("套图提示词设置") + self.setMinimumSize(760, 480) + self.resize(920, 600) + self.prompt_path = str(prompt_path) + self.base_prompt = str(base_prompt or "") + self.settings = product_suite.normalize_suite_settings(settings) + self.item_id = str(item_id or "") + self._saved_text = "" + self._allow_close = False + + self._build_ui() + self._connect_signals() + self._load_current_template() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + toolbar = QHBoxLayout() + toolbar.setSpacing(8) + self.save_button = QPushButton("保存") + self.save_button.setObjectName("suitePromptSaveButton") + self.save_button.setMinimumHeight(32) + toolbar.addWidget(self.save_button) + self.restore_button = QPushButton("恢复默认") + self.restore_button.setObjectName("suitePromptRestoreButton") + self.restore_button.setMinimumHeight(32) + toolbar.addWidget(self.restore_button) + self.feedback_label = QLabel("") + self.feedback_label.setObjectName("suitePromptFeedbackLabel") + self.feedback_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + toolbar.addWidget(self.feedback_label, 1) + toolbar.addWidget(QLabel("预览分类")) + self.category_combo = QComboBox() + self.category_combo.setObjectName("suitePromptCategoryCombo") + self.category_combo.setMinimumWidth(128) + for category in product_suite.category_order(self.settings): + self.category_combo.addItem(category, category) + white_index = self.category_combo.findData("白底图") + if white_index >= 0: + self.category_combo.setCurrentIndex(white_index) + toolbar.addWidget(self.category_combo) + root.addLayout(toolbar) + + self.splitter = QSplitter(Qt.Horizontal) + self.splitter.setObjectName("suitePromptSplitter") + self.splitter.addWidget(self._build_editor_panel()) + self.splitter.addWidget(self._build_preview_panel()) + self.splitter.setStretchFactor(0, 1) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([440, 440]) + root.addWidget(self.splitter, 1) + + def _build_editor_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 4, 0) + layout.setSpacing(6) + title_row = QHBoxLayout() + title = QLabel("基础模板") + title.setStyleSheet("font-weight: 600;") + title_row.addWidget(title) + title_row.addStretch(1) + self.variable_button = QToolButton() + self.variable_button.setObjectName("suitePromptVariableButton") + self.variable_button.setText("插入变量") + self.variable_button.setToolTip("在当前光标位置插入提示词变量") + self.variable_button.setAccessibleName("插入提示词变量") + self.variable_button.setPopupMode(QToolButton.InstantPopup) + menu = QMenu(self.variable_button) + for name in product_suite.PRODUCT_SUITE_PLACEHOLDERS: + action = menu.addAction("{%s}" % name) + action.triggered.connect( + lambda checked=False, variable=name: self.insert_variable(variable) + ) + self.variable_button.setMenu(menu) + title_row.addWidget(self.variable_button) + layout.addLayout(title_row) + + self.template_edit = QPlainTextEdit() + self.template_edit.setObjectName("suitePromptTemplateEdit") + self.template_edit.setPlaceholderText("输入套图提示词模板") + self.template_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) + layout.addWidget(self.template_edit, 1) + + self.validation_label = QLabel("") + self.validation_label.setObjectName("suitePromptValidationLabel") + self.validation_label.setWordWrap(True) + self.validation_label.setStyleSheet("color: #cf222e;") + layout.addWidget(self.validation_label) + return panel + + def _build_preview_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(4, 0, 0, 0) + layout.setSpacing(6) + title = QLabel("最终提示词预览") + title.setStyleSheet("font-weight: 600;") + layout.addWidget(title) + self.preview_edit = QPlainTextEdit() + self.preview_edit.setObjectName("suitePromptPreviewEdit") + self.preview_edit.setReadOnly(True) + self.preview_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) + self.preview_edit.setStyleSheet( + "QPlainTextEdit#suitePromptPreviewEdit { " + "background: #f6f8fa; color: #24292f; border: 1px solid #d0d7de; " + "}" + ) + layout.addWidget(self.preview_edit, 1) + return panel + + def _connect_signals(self): + self.save_button.clicked.connect(self.save_template) + self.restore_button.clicked.connect(self.restore_default) + self.category_combo.currentIndexChanged.connect(self.schedule_preview) + self.template_edit.textChanged.connect(self.schedule_preview) + self.preview_timer = QTimer(self) + self.preview_timer.setSingleShot(True) + self.preview_timer.setInterval(200) + self.preview_timer.timeout.connect(self.refresh_preview) + self.feedback_timer = QTimer(self) + self.feedback_timer.setSingleShot(True) + self.feedback_timer.setInterval(3000) + self.feedback_timer.timeout.connect(lambda: self.feedback_label.setText("")) + + def _load_current_template(self): + try: + text = prompts.read_product_suite_prompt(self.prompt_path) + except prompts.PromptError as exc: + text = "" + self._set_feedback(str(exc), "danger") + self._saved_text = text + self.template_edit.setPlainText(text) + self.refresh_preview() + + def insert_variable(self, name): + if name not in product_suite.PRODUCT_SUITE_PLACEHOLDERS: + return + cursor = self.template_edit.textCursor() + cursor.insertText("{%s}" % name) + self.template_edit.setTextCursor(cursor) + self.template_edit.setFocus() + + def schedule_preview(self, value=None): + self.preview_timer.start() + + def preview_context(self): + category = str(self.category_combo.currentData() or "白底图") + return product_suite.product_suite_prompt_context( + self.base_prompt, + self.settings, + category, + self.item_id or "未绑定商品", + source_index=1, + ) + + def refresh_preview(self): + text = self.template_edit.toPlainText() + errors = product_suite.product_suite_prompt_errors(text) + self.save_button.setEnabled(not errors) + self.validation_label.setText(";".join(errors)) + if errors: + self.preview_edit.setPlainText( + "套图提示词模板无效:\n%s" + % "\n".join("- %s" % error for error in errors) + ) + return + try: + rendered = product_suite.render_product_suite_prompt( + text, + self.preview_context(), + ) + except product_suite.ProductSuitePromptError as exc: + self.save_button.setEnabled(False) + self.validation_label.setText(str(exc)) + self.preview_edit.setPlainText("套图提示词模板无效:\n%s" % exc) + return + self.preview_edit.setPlainText(rendered) + + def save_template(self): + text = self.template_edit.toPlainText() + try: + prompts.save_product_suite_prompt(text, self.prompt_path) + except prompts.PromptError as exc: + self._message("提示词保存失败", str(exc)) + return False + self._saved_text = text + self.refresh_preview() + self._set_feedback("套图提示词模板已保存", "success") + return True + + def restore_default(self): + try: + prompts.load_default_product_suite_prompt() + except prompts.PromptError as exc: + self._message("恢复默认失败", str(exc)) + return False + if not self._confirm_restore(): + return False + try: + text = prompts.restore_default_product_suite_prompt(self.prompt_path) + except prompts.PromptError as exc: + self._message("恢复默认失败", str(exc)) + return False + self._saved_text = text + previous = self.template_edit.blockSignals(True) + try: + self.template_edit.setPlainText(text) + finally: + self.template_edit.blockSignals(previous) + self.refresh_preview() + self._set_feedback("已恢复默认套图提示词", "success") + return True + + def is_dirty(self): + return self.template_edit.toPlainText() != self._saved_text + + def _confirm_restore(self): + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("恢复默认提示词") + box.setText("恢复默认会丢弃当前未保存的模板修改。确认继续吗?") + restore_button = box.addButton("恢复默认", QMessageBox.AcceptRole) + box.addButton("取消", QMessageBox.RejectRole) + box.exec() + return box.clickedButton() is restore_button + + def _unsaved_action(self): + box = QMessageBox(self) + box.setIcon(QMessageBox.Question) + box.setWindowTitle("提示词尚未保存") + box.setText("当前套图提示词模板有未保存修改。") + save_button = box.addButton("保存", QMessageBox.AcceptRole) + discard_button = box.addButton("不保存", QMessageBox.DestructiveRole) + cancel_button = box.addButton("取消", QMessageBox.RejectRole) + box.setDefaultButton(save_button) + box.exec() + clicked = box.clickedButton() + if clicked is save_button: + return "save" + if clicked is discard_button: + return "discard" + if clicked is cancel_button: + return "cancel" + return "cancel" + + def _can_close(self): + if not self.is_dirty(): + return True + action = self._unsaved_action() + if action == "save": + return self.save_template() + return action == "discard" + + def reject(self): + if self._can_close(): + self._allow_close = True + super().reject() + + def closeEvent(self, event): + if self._allow_close or self._can_close(): + event.accept() + else: + event.ignore() + + def _set_feedback(self, message, level): + color = "#1a7f37" if level == "success" else "#cf222e" + self.feedback_label.setStyleSheet("color: %s; font-weight: 600;" % color) + self.feedback_label.setText(str(message)) + self.feedback_timer.start() + + def _message(self, title, message): + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle(str(title)) + box.setText(str(message)) + box.exec() diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index f034549..b2844d5 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -40,9 +40,18 @@ from PySide6.QtWidgets import ( QWidget, ) -from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite +from ... import ( + accounts, + appconfig, + diagnostics, + image_studio, + image_studio_images, + product_suite, + prompts, +) from .. import file_manager from ..image_preview import ImagePreviewDialog +from ..product_suite_prompt_dialog import ProductSuitePromptDialog from ..widgets import _emit_status, run_worker from ..workers import ( ImageStudioDownloadOriginalWorker, @@ -57,13 +66,6 @@ ORIGINAL_DOWNLOAD_CONCURRENCY = 2 ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 _PRODUCT_SUITE_THREAD_REFS = {} _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) -FIXED_CATEGORY_HELPERS = { - "白底图": "白底主图,多角度呈现商品细节", - "场景图": "生活化场景展示商品使用方式", - "卖点图": "突出核心卖点和差异化优势", -} - - def _asset_usable(asset): path = str(getattr(asset, "local_path", "") or "") return ( @@ -663,6 +665,7 @@ class ProductSuiteTab(QWidget): self.db_path = db_path or appconfig.db_path(self.config) self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config) + self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config) self.status_callback = status_callback self.accounts = [] self._states = {} @@ -673,6 +676,12 @@ class ProductSuiteTab(QWidget): self._original_list_context = None self._loading = False self._result_refresh_pending = False + self._prompt_template_init_error = "" + + try: + prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path) + except prompts.PromptError as exc: + self._prompt_template_init_error = str(exc) self._build_ui() self._connect_signals() @@ -684,6 +693,8 @@ class ProductSuiteTab(QWidget): self.elapsed_timer.setInterval(1000) self.elapsed_timer.timeout.connect(self._refresh_elapsed) self.elapsed_timer.start() + if self._prompt_template_init_error: + self._status(self._prompt_template_init_error, "danger") def _build_ui(self): root = QVBoxLayout(self) @@ -956,17 +967,23 @@ class ProductSuiteTab(QWidget): layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) title_row = QHBoxLayout() + self.prompt_title_layout = title_row title = QLabel("商品卖点与要求") + self.prompt_title_label = title title.setStyleSheet("font-weight: 600;") title_row.addWidget(title) - title_row.addStretch(1) + self.ai_write_button = QPushButton("AI 帮写") + self.ai_write_button.setObjectName("suiteAiWriteButton") + title_row.addWidget(self.ai_write_button) self.ai_cancel_button = QPushButton("取消") self.ai_cancel_button.setObjectName("suiteAiCancelButton") self.ai_cancel_button.hide() title_row.addWidget(self.ai_cancel_button) - self.ai_write_button = QPushButton("AI 帮写") - self.ai_write_button.setObjectName("suiteAiWriteButton") - title_row.addWidget(self.ai_write_button) + title_row.addStretch(1) + self.prompt_settings_button = QPushButton("提示词设置") + self.prompt_settings_button.setObjectName("suitePromptSettingsButton") + self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词") + title_row.addWidget(self.prompt_settings_button) layout.addLayout(title_row) self.prompt_edit = QPlainTextEdit() self.prompt_edit.setObjectName("suitePromptEdit") @@ -1098,6 +1115,7 @@ class ProductSuiteTab(QWidget): self.prompt_edit.textChanged.connect(self._on_prompt_changed) self.ai_write_button.clicked.connect(self.start_ai_write) self.ai_cancel_button.clicked.connect(self.cancel_ai_write) + self.prompt_settings_button.clicked.connect(self.open_prompt_settings) self.add_category_button.clicked.connect(self.add_custom_category) self.custom_category_edit.returnPressed.connect(self._commit_custom_category) self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit) @@ -1638,6 +1656,27 @@ class ProductSuiteTab(QWidget): state = self._displayed_state state.prompt = self.prompt_edit.toPlainText() + def open_prompt_settings(self, checked=False): + state = self._displayed_state + if state is not None: + self._save_controls_to_state(state) + settings = state.settings + base_prompt = state.prompt + item_id = state.item_id or "未绑定商品" + else: + settings = product_suite.default_suite_settings() + base_prompt = "" + item_id = "未绑定商品" + dialog = ProductSuitePromptDialog( + prompt_path=self.product_suite_prompt_path, + base_prompt=base_prompt, + settings=settings, + item_id=item_id, + parent=self, + ) + self.prompt_settings_dialog = dialog + dialog.exec() + def _update_context_actions(self, state): is_draft = self._is_draft_state(state) self.pull_button.setEnabled( @@ -2093,7 +2132,7 @@ class ProductSuiteTab(QWidget): row = SuiteCategoryRow( name, categories.get(name, 0), - helper=FIXED_CATEGORY_HELPERS.get(name, ""), + helper=product_suite.category_helper(name), custom=custom, ) row.countChangeRequested.connect(self.change_category_count) @@ -2327,6 +2366,18 @@ class ProductSuiteTab(QWidget): return False if state is self._displayed_state: self._save_controls_to_state(state) + template_text = None + if specs is None: + try: + template_text = prompts.load_product_suite_prompt( + self.product_suite_prompt_path + ) + except prompts.PromptError: + self._message( + "套图提示词模板无效", + "套图提示词模板无效,请在提示词设置中修复或恢复默认。", + ) + return False if self._ensure_project_for_local_work(state) is None: return False local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] @@ -2341,6 +2392,7 @@ class ProductSuiteTab(QWidget): state.prompt, state.settings, state.item_id or "未绑定商品", + template_text=template_text, )) if not specs: self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") diff --git a/app/product_suite.py b/app/product_suite.py index 65c31c1..70459ee 100644 --- a/app/product_suite.py +++ b/app/product_suite.py @@ -3,9 +3,15 @@ from __future__ import annotations from collections import OrderedDict +import re FIXED_CATEGORIES = ("白底图", "场景图", "卖点图") +FIXED_CATEGORY_HELPERS = { + "白底图": "白底主图,多角度呈现商品细节", + "场景图": "生活化场景展示商品使用方式", + "卖点图": "突出核心卖点和差异化优势", +} DEFAULT_CATEGORY_COUNTS = OrderedDict( (("白底图", 1), ("场景图", 2), ("卖点图", 2)) ) @@ -16,6 +22,60 @@ RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16") LAST_SETTING_KEYS = ("platform", "country", "language", "ratio") MAX_CATEGORY_NAME_LENGTH = 10 MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16 +PRODUCT_SUITE_PLACEHOLDERS = ( + "套图名称", + "补充描述", + "平台", + "国家地区", + "输出语言", + "图片比例", + "商品ID", + "主参考图序号", + "参考图规则", + "商品卖点与要求", + "尺寸与长图规则", + "禁用内容规则", + "价格信息规则", + "尺码信息规则", +) +PRODUCT_SUITE_REQUIRED_PLACEHOLDERS = ( + "套图名称", + "补充描述", + "图片比例", + "参考图规则", + "商品卖点与要求", + "尺寸与长图规则", + "禁用内容规则", + "价格信息规则", + "尺码信息规则", +) +PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS = ( + "尺寸与长图规则", + "禁用内容规则", + "价格信息规则", + "尺码信息规则", +) +PRODUCT_SUITE_SIZE_RULE = ( + "重要尺寸要求:最终输出必须严格符合所选比例的单张完整构图电商图," + "禁止海报长图、详情页长图和多宫格拼接版面。" +) +PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE = ( + "重要禁用内容:禁止在画面中出现任何国旗、旗帜、国徽、地图轮廓、" + "政治符号或类似国家/地区标识。" +) +PRODUCT_SUITE_PRICE_RULE = ( + "价格信息规则:除非用户明确提供价格、折扣或活动价,否则禁止自行添加" + "价格、币别符号、折扣数字或促销金额。" +) +PRODUCT_SUITE_SIZE_INFO_RULE = ( + "尺码信息规则:除非用户或参考图明确提供尺码、尺寸或规格,否则禁止自行" + "编造尺码、尺寸、适用身高体重等内容。" +) +_PRODUCT_SUITE_PLACEHOLDER_RE = re.compile(r"\{([^{}\r\n]+)\}") + + +class ProductSuitePromptError(ValueError): + """Raised when a product-suite prompt template is invalid.""" def default_suite_settings(): @@ -89,6 +149,15 @@ def category_order(settings): return list(FIXED_CATEGORIES) + custom +def category_helper(category): + return FIXED_CATEGORY_HELPERS.get(str(category or ""), "") + + +def category_description(category): + helper = category_helper(category) + return "," + helper if helper else "" + + def suite_total_count(settings, image_count): normalized = normalize_suite_settings(settings) categories = normalized["categories"] @@ -100,24 +169,129 @@ def suite_total_count(settings, image_count): return white_count + other_count * max(1, int(image_count or 0)) -def build_suite_prompt(base_prompt, settings, category, item_id, source_index=1): - normalized = normalize_suite_settings(settings) - context = [ - "生成一张电商商品套图。", - "平台:%s" % normalized["platform"], - "国家地区:%s" % normalized["country"], - "输出语言:%s" % normalized["language"], - "图片比例:%s" % normalized["ratio"], - "套图分类:%s" % str(category), - "商品ID:%s" % str(item_id or ""), - "当前主参考图序号:%d" % max(1, int(source_index or 1)), - "商品卖点与要求:%s" % str(base_prompt or "").strip(), - "保持商品主体、款式、颜色和关键细节准确,不添加无依据的功能或参数。", +def product_suite_prompt_errors(template_text): + text = str(template_text or "") + errors = [] + if not text.strip(): + return ["套图提示词模板不能为空"] + + matches = list(_PRODUCT_SUITE_PLACEHOLDER_RE.finditer(text)) + remainder = _PRODUCT_SUITE_PLACEHOLDER_RE.sub("", text) + if "{" in remainder or "}" in remainder: + errors.append("模板包含未闭合花括号或不支持的字面花括号") + + names = [match.group(1) for match in matches] + unknown = sorted(set(names) - set(PRODUCT_SUITE_PLACEHOLDERS)) + if unknown: + errors.append("模板包含未知变量:%s" % "、".join("{%s}" % name for name in unknown)) + + missing = [ + name for name in PRODUCT_SUITE_REQUIRED_PLACEHOLDERS if name not in names ] - return "\n".join(context) + if missing: + errors.append("模板缺少必需变量:%s" % "、".join("{%s}" % name for name in missing)) + + invalid_rule_lines = [] + for line in text.splitlines(): + line_names = _PRODUCT_SUITE_PLACEHOLDER_RE.findall(line) + for name in line_names: + if ( + name in PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS + and line.strip() != "{%s}" % name + ): + invalid_rule_lines.append(name) + if invalid_rule_lines: + errors.append( + "只读规则变量必须独占一行:%s" + % "、".join("{%s}" % name for name in sorted(set(invalid_rule_lines))) + ) + return errors -def build_job_specs(source_assets, base_prompt, settings, item_id): +def validate_product_suite_prompt(template_text): + errors = product_suite_prompt_errors(template_text) + if errors: + raise ProductSuitePromptError(";".join(errors)) + return str(template_text) + + +def product_suite_prompt_context( + base_prompt, + settings, + category, + item_id, + source_index=1, +): + normalized = normalize_suite_settings(settings) + item_text = str(item_id or "").strip() + if not item_text or item_text.startswith("draft_"): + item_text = "未绑定商品" + reference_index = max(1, int(source_index or 1)) + return { + "套图名称": str(category or ""), + "补充描述": category_description(category), + "平台": normalized["platform"], + "国家地区": normalized["country"], + "输出语言": normalized["language"], + "图片比例": normalized["ratio"], + "商品ID": item_text, + "主参考图序号": str(reference_index), + "参考图规则": ( + "参考图规则:当前上传图片是本任务唯一主参考图(序号%d);保持商品主体、" + "款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。" + % reference_index + ), + "商品卖点与要求": str(base_prompt or "").strip(), + "尺寸与长图规则": PRODUCT_SUITE_SIZE_RULE, + "禁用内容规则": PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE, + "价格信息规则": PRODUCT_SUITE_PRICE_RULE, + "尺码信息规则": PRODUCT_SUITE_SIZE_INFO_RULE, + } + + +def render_product_suite_prompt(template_text, context): + validate_product_suite_prompt(template_text) + values = { + name: str((context or {}).get(name, "")) + for name in PRODUCT_SUITE_PLACEHOLDERS + } + missing_context = [ + name for name in PRODUCT_SUITE_REQUIRED_PLACEHOLDERS if name not in (context or {}) + ] + if missing_context: + raise ProductSuitePromptError( + "提示词上下文缺少变量:%s" + % "、".join("{%s}" % name for name in missing_context) + ) + rendered = _PRODUCT_SUITE_PLACEHOLDER_RE.sub( + lambda match: values[match.group(1)], + str(template_text), + ) + if "{" in rendered or "}" in rendered: + raise ProductSuitePromptError("提示词渲染后仍有未替换变量") + return rendered.strip() + + +def build_suite_prompt( + base_prompt, + settings, + category, + item_id, + source_index=1, + *, + template_text, +): + context = product_suite_prompt_context( + base_prompt, + settings, + category, + item_id, + source_index=source_index, + ) + return render_product_suite_prompt(template_text, context) + + +def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_text): assets = list(source_assets or []) if not assets: return [] @@ -143,6 +317,7 @@ def build_job_specs(source_assets, base_prompt, settings, item_id): category, item_id, source_index=source_index, + template_text=template_text, ), } ) diff --git a/app/prompts.py b/app/prompts.py index ed1e614..d3a88cc 100644 --- a/app/prompts.py +++ b/app/prompts.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import tempfile from importlib import resources from . import appconfig @@ -11,10 +12,14 @@ TITLE_PROMPT_PATH = appconfig.title_prompt_path() TITLE_TEMPLATES_DIR = appconfig.title_templates_dir() COVER_PROMPTS_DIR = appconfig.cover_prompts_dir() IMAGE_STUDIO_PROMPTS_DIR = appconfig.image_studio_prompts_dir() +PRODUCT_SUITE_PROMPT_PATH = appconfig.product_suite_prompt_path() TEMPLATE_EXT = ".txt" INVALID_NAME_CHARS = set('\\/:*?"<>|') DEFAULT_PROMPTS_PACKAGE = "app.default_prompts" DEFAULT_COVER_PROMPTS_PACKAGE = "app.default_prompts.cover" +DEFAULT_PRODUCT_SUITE_PROMPTS_PACKAGE = "app.default_prompts.product_suite" +DEFAULT_PRODUCT_SUITE_PROMPT_NAME = "base.txt" +PRODUCT_SUITE_DEFAULT_ERROR = "内置套图提示词模板无效,请重新安装软件或联系技术支持。" class PromptError(RuntimeError): @@ -69,6 +74,74 @@ def ensure_default_prompts( save_cover_template(name, text, cover_prompts_dir) +def read_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str: + """Read a product-suite template without hiding invalid user content.""" + + if not os.path.exists(path): + return "" + try: + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + except (OSError, UnicodeError) as exc: + raise PromptError("套图提示词模板读取失败:%s" % exc) from exc + + +def load_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str: + """Load and validate the current user product-suite template.""" + + text = read_product_suite_prompt(path) + if not text.strip(): + raise PromptError("套图提示词模板不存在或为空") + _validate_product_suite_prompt(text, "套图提示词模板无效") + return text + + +def load_default_product_suite_prompt() -> str: + """Load and validate the packaged product-suite template.""" + + try: + text = ( + resources.files(DEFAULT_PRODUCT_SUITE_PROMPTS_PACKAGE) + .joinpath(DEFAULT_PRODUCT_SUITE_PROMPT_NAME) + .read_text(encoding="utf-8") + ) + except (FileNotFoundError, ModuleNotFoundError, OSError, UnicodeError) as exc: + raise PromptError(PRODUCT_SUITE_DEFAULT_ERROR) from exc + try: + _validate_product_suite_prompt(text, "内置套图提示词模板无效") + except PromptError as exc: + raise PromptError(PRODUCT_SUITE_DEFAULT_ERROR) from exc + return text + + +def save_product_suite_prompt(text, path=PRODUCT_SUITE_PROMPT_PATH) -> None: + """Validate and atomically save the user product-suite template.""" + + _validate_product_suite_prompt(text, "套图提示词模板无效") + _atomic_write_text(path, str(text)) + + +def ensure_default_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str: + """Seed a validated packaged template without overwriting user content.""" + + if os.path.exists(path): + current = read_product_suite_prompt(path) + if current.strip(): + _validate_product_suite_prompt(current, "套图提示词模板无效") + return current + default_text = load_default_product_suite_prompt() + save_product_suite_prompt(default_text, path) + return default_text + + +def restore_default_product_suite_prompt(path=PRODUCT_SUITE_PROMPT_PATH) -> str: + """Validate and atomically restore the packaged product-suite template.""" + + default_text = load_default_product_suite_prompt() + save_product_suite_prompt(default_text, path) + return default_text + + def list_templates(directory): """Return prompt template names sorted by display name.""" @@ -273,6 +346,45 @@ def _has_non_empty_file(path) -> bool: return False +def _validate_product_suite_prompt(text, prefix) -> None: + from . import product_suite + + try: + product_suite.validate_product_suite_prompt(text) + except product_suite.ProductSuitePromptError as exc: + raise PromptError("%s:%s" % (prefix, exc)) from exc + + +def _atomic_write_text(path, text) -> None: + target = os.path.abspath(path) + directory = os.path.dirname(target) + if directory: + os.makedirs(directory, exist_ok=True) + temporary_path = "" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=directory or None, + prefix=".prompt-", + suffix=".tmp", + delete=False, + ) as handle: + temporary_path = handle.name + handle.write(str(text)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, target) + except OSError as exc: + raise PromptError("套图提示词模板保存失败:%s" % exc) from exc + finally: + if temporary_path and os.path.exists(temporary_path): + try: + os.remove(temporary_path) + except OSError: + pass + + def _read_default_prompt_text(filename) -> str: try: return ( diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 334ab20..5f65f7d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -430,7 +430,8 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 -- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。 +- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。 +- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。允许占位符为 `{套图名称}`、`{补充描述}`、平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点及四个只读规则;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入参考图规则。比例仍同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,进入 cmhub 请求与输出资产元数据。 - `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 - ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 @@ -441,6 +442,7 @@ data/images///__new. # AI 生成的新 - **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,左侧模板行提供下拉、新建、保存模板、另存为、重命名、删除;模板只负责被用户选中后载入编辑框,或把当前编辑框内容保存为命名模板,不改变生成读取路径,也不在启动时覆盖 `title_prompt.txt`。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。 - **封面提示词**:多模板。左侧模板行提供下拉(读 `data/prompts/cover/*.txt`)、新建、保存模板、模板操作(另存为/重命名/删除);重名校验、删除二次确认、删空给默认。 +- **商品套图提示词**:单一全局基础模板,用户文件为 `data/prompts/product_suite/base.txt`,内置恢复源为 `app/default_prompts/product_suite/base.txt`。⑥「商品卖点与要求」标题行提供「提示词设置」;弹窗左侧编辑模板、右侧按白底图或其他当前分类只读预览最终请求,编辑使用 200ms 防抖。保存与「恢复默认」共用同一个 UTF-8 原子写入服务;恢复默认会先校验内置资源并经确认立即落盘。用户可移动只读规则占位符位置,但不能修改规则内容或删除必需变量;预览和新 job prompt 必须逐字一致。 - **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。 ### 6.3 应用更新(③ Tab) diff --git a/docs/tasks/T-637.md b/docs/tasks/T-637.md index 50a1309..435142a 100644 --- a/docs/tasks/T-637.md +++ b/docs/tasks/T-637.md @@ -1,7 +1,7 @@ --- id: T-637 title: 商品套图提示词设置弹窗与实时最终预览 -status: TODO +status: DONE phase: 7 deps: [T-634, T-636] created: 2026-07-16 @@ -231,3 +231,7 @@ git diff --check - 2026-07-16 补:按用户要求把默认模板行序对齐 obsidian《虾皮圈电商图生成器提示词组装分析》第三节的组装顺序(用户接受的结构)——强制规则从「文末代码追加」改为四个必需只读占位符(尺寸与长图/禁用内容/价格/尺码)内联在平台行与参考图规则之间;比例强调移至末行;商品一致性与不编造并入 `{参考图规则}` 渲染文本;占位符总数 13→17,必需 8→12。 - 2026-07-16 补:补齐首次初始化和重复占位符语义——内置模板首次复制前同样必须校验,失败时不创建用户文件并阻断生成;重复 `{差异化要求}`的每次出现都必须独占一行,总数为 1 时定点删除全部对应行;历史执行记录标注已被后续结论覆盖。 - 2026-07-16 简:按用户要求简化模板结构——删除 `{分类要求}`、`{差异化要求}`、`{分类序号}`、`{分类总数}`四个占位符及默认模板中的「商品ID:{商品ID}」行;`{套图分类}`改名为 `{套图名称}`并新增 `{补充描述}`(白底图/场景图/卖点图有说明、自定义分类为空);连带去掉弹窗「预览第 X 张」步进器和同类多张差异化机制(第一版沿用源项目依赖模型随机性区分同类多张);占位符 17→14、必需 12→9;`{商品ID}`、`{主参考图序号}`降为可选可插入变量。 +- 2026-07-16:新增 `app/default_prompts/product_suite/base.txt` 与 `data/prompts/product_suite/base.txt` 用户路径;实现内置模板首次校验初始化、无效用户模板保留、统一 UTF-8 原子保存和恢复默认。14 个占位符由统一校验器处理,9 个必需变量及四个只读规则变量执行完整结构校验。 +- 2026-07-16:`app/product_suite.py` 集中固定分类补充描述、尺寸/禁用内容/价格/尺码规则和单参考图一致性规则;弹窗预览、`build_suite_prompt()` 与 `build_job_specs()` 共用同一个 renderer。新一轮生成在创建项目/job/worker 前加载并冻结模板;历史单张重试继续使用原 `job.prompt`。 +- 2026-07-16:新增独立 `ProductSuitePromptDialog`,实现标题行「AI帮写 / 取消 / 提示词设置」布局、左右等宽编辑/只读预览、分类切换、插入变量、200ms 防抖、就地中文校验、保存/恢复默认和未保存关闭三选项;离屏 `920x600` 截图检查无控件重叠。 +- 2026-07-16:补齐 prompts、product_suite、GUI 和打包资源测试。当前工作区专项 40 项通过;全仓在隔离工作树验证 518 项 unittest、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。当前主工作区原有封面默认文件改名会使旧 `papa1` 单测失败,未纳入、未修改该用户改动。 diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 2a06456..6baf8f1 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -32,6 +32,9 @@ class PackagingTests(unittest.TestCase): self.assertIn("datas=default_prompt_datas", normalized) self.assertIn("COLLECT(", spec) self.assertIn('name="cmshopee"', normalized) + suite_prompt = self.read_text("app/default_prompts/product_suite/base.txt") + self.assertIn("{套图名称}", suite_prompt) + self.assertIn("{尺寸与长图规则}", suite_prompt) def test_build_script_blocks_user_data_in_release_output(self): script = self.read_text("scripts/build_exe.ps1") diff --git a/tests/test_product_suite.py b/tests/test_product_suite.py index 9f7eead..016a2fd 100644 --- a/tests/test_product_suite.py +++ b/tests/test_product_suite.py @@ -5,7 +5,7 @@ from types import SimpleNamespace sys.path.insert(0, os.path.dirname(__file__)) -from app import product_suite +from app import product_suite, prompts class ProductSuiteTests(unittest.TestCase): @@ -88,6 +88,7 @@ class ProductSuiteTests(unittest.TestCase): "40小时续航,适合通勤", settings, "51100639510", + template_text=prompts.load_default_product_suite_prompt(), ) self.assertEqual(3, len(specs)) @@ -97,9 +98,81 @@ class ProductSuiteTests(unittest.TestCase): self.assertIn("平台:Shopee", spec["prompt"]) self.assertIn("国家地区:中国台湾", spec["prompt"]) self.assertIn("输出语言:繁体中文", spec["prompt"]) - self.assertIn("图片比例:4:3", spec["prompt"]) - self.assertIn("商品ID:51100639510", spec["prompt"]) + self.assertIn("本次生成比例:4:3", spec["prompt"]) + self.assertNotIn("商品ID:51100639510", spec["prompt"]) self.assertIn("40小时续航", spec["prompt"]) + self.assertIn("禁止海报长图、详情页长图和多宫格拼接版面", spec["prompt"]) + self.assertIn("禁止在画面中出现任何国旗", spec["prompt"]) + self.assertIn("当前上传图片是本任务唯一主参考图", spec["prompt"]) + self.assertTrue( + spec["prompt"].splitlines()[-1].startswith("本次生成比例:4:3") + ) + + self.assertIn("套图名称:白底图,白底主图", specs[0]["prompt"]) + self.assertIn("套图名称:场景图,生活化场景", specs[1]["prompt"]) + white_prompt = specs[0]["prompt"] + self.assertLess(white_prompt.index("重要尺寸要求"), white_prompt.index("重要禁用内容")) + self.assertLess(white_prompt.index("重要禁用内容"), white_prompt.index("价格信息规则")) + self.assertLess(white_prompt.index("价格信息规则"), white_prompt.index("尺码信息规则")) + self.assertLess(white_prompt.index("尺码信息规则"), white_prompt.index("参考图规则")) + self.assertLess(white_prompt.index("参考图规则"), white_prompt.index("商品卖点与要求")) + + def test_product_suite_template_validation_and_custom_category_rendering(self): + default_text = prompts.load_default_product_suite_prompt() + self.assertEqual([], product_suite.product_suite_prompt_errors(default_text)) + + unknown = default_text + "\n{未知变量}" + self.assertIn("未知变量", ";".join(product_suite.product_suite_prompt_errors(unknown))) + + missing = default_text.replace("{图片比例}", "") + self.assertIn( + "缺少必需变量", + ";".join(product_suite.product_suite_prompt_errors(missing)), + ) + + inline_rule = default_text.replace( + "{价格信息规则}", + "价格:{价格信息规则}", + ) + self.assertIn( + "只读规则变量必须独占一行", + ";".join(product_suite.product_suite_prompt_errors(inline_rule)), + ) + + literal_brace = default_text + "\n普通内容{" + self.assertIn( + "不支持的字面花括号", + ";".join(product_suite.product_suite_prompt_errors(literal_brace)), + ) + + settings = product_suite.default_suite_settings() + context = product_suite.product_suite_prompt_context( + "突出轻量材质", + settings, + "尺寸图", + "draft_hidden", + ) + rendered = product_suite.render_product_suite_prompt(default_text, context) + self.assertIn("套图名称:尺寸图", rendered) + self.assertNotIn("draft_", rendered) + self.assertNotIn("白底主图", rendered) + + def test_product_suite_optional_item_and_reference_variables(self): + template = prompts.load_default_product_suite_prompt() + ( + "\n商品ID:{商品ID}\n参考图序号:{主参考图序号}" + ) + context = product_suite.product_suite_prompt_context( + "卖点", + product_suite.default_suite_settings(), + "卖点图", + "", + source_index=2, + ) + + rendered = product_suite.render_product_suite_prompt(template, context) + + self.assertIn("商品ID:未绑定商品", rendered) + self.assertIn("参考图序号:2", rendered) if __name__ == "__main__": diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 6664ae0..75f0775 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -8,14 +8,14 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin -from app import accounts, appconfig, image_studio, image_studio_images +from app import accounts, appconfig, image_studio, image_studio_images, product_suite, prompts from app import gui if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") from PySide6.QtCore import QMimeData, Qt, QUrl -from PySide6.QtGui import QIcon, QImage, QPixmap +from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton @@ -26,6 +26,7 @@ from app.gui.tabs.product_suite import ( ProductSuiteTab, SuiteResultCard, ) +from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): @@ -150,6 +151,19 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("打开结果文件夹", tab.open_folder_button.text()) self.assertEqual("合计 5 张", tab.category_total_label.text()) self.assertEqual("生成套图(5)", tab.generate_button.text()) + self.assertLess( + tab.prompt_title_layout.indexOf(tab.prompt_title_label), + tab.prompt_title_layout.indexOf(tab.ai_write_button), + ) + self.assertLess( + tab.prompt_title_layout.indexOf(tab.ai_write_button), + tab.prompt_title_layout.indexOf(tab.ai_cancel_button), + ) + self.assertLess( + tab.prompt_title_layout.indexOf(tab.ai_cancel_button), + tab.prompt_title_layout.indexOf(tab.prompt_settings_button), + ) + self.assertEqual("提示词设置", tab.prompt_settings_button.text()) visible_text = " ".join( [widget.text() for widget in tab.findChildren(QLabel)] @@ -171,6 +185,139 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_prompt_settings_dialog_previews_validates_saves_and_restores(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + prompt_path = appconfig.product_suite_prompt_path(config) + prompts.ensure_default_product_suite_prompt(prompt_path) + dialog = ProductSuitePromptDialog( + prompt_path=prompt_path, + base_prompt="40小时续航,适合通勤", + settings=product_suite.default_suite_settings(), + item_id="51100639510", + ) + self.addCleanup(dialog.close) + dialog.show() + self.app.processEvents() + + self.assertEqual("白底图", dialog.category_combo.currentData()) + self.assertTrue(dialog.preview_edit.isReadOnly()) + self.assertIn("套图名称:白底图,白底主图", dialog.preview_edit.toPlainText()) + self.assertIn("40小时续航", dialog.preview_edit.toPlainText()) + sizes = dialog.splitter.sizes() + self.assertLessEqual(abs(sizes[0] - sizes[1]), 12) + + cursor = dialog.template_edit.textCursor() + cursor.movePosition(QTextCursor.End) + dialog.template_edit.setTextCursor(cursor) + dialog.insert_variable("商品ID") + self.assertTrue(dialog.template_edit.toPlainText().endswith("{商品ID}")) + + default_text = prompts.load_default_product_suite_prompt() + invalid_text = default_text.replace("{图片比例}", "") + dialog.template_edit.setPlainText(invalid_text) + QTest.qWait(230) + self.assertFalse(dialog.save_button.isEnabled()) + self.assertIn("缺少必需变量", dialog.validation_label.text()) + + custom_text = "自定义规则\n" + default_text + dialog.template_edit.setPlainText(custom_text) + QTest.qWait(230) + self.assertTrue(dialog.save_button.isEnabled()) + self.assertTrue(dialog.save_template()) + self.assertEqual(custom_text, prompts.load_product_suite_prompt(prompt_path)) + self.assertFalse(dialog.is_dirty()) + + dialog.template_edit.setPlainText("临时未保存\n" + custom_text) + with mock.patch.object(dialog, "_confirm_restore", return_value=True): + self.assertTrue(dialog.restore_default()) + self.assertEqual(default_text, dialog.template_edit.toPlainText()) + self.assertEqual(default_text, prompts.load_product_suite_prompt(prompt_path)) + self.assertFalse(dialog.is_dirty()) + + self.assert_removed(temp_dir) + + def test_prompt_settings_dialog_unsaved_close_uses_chinese_three_way_action(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + prompt_path = appconfig.product_suite_prompt_path(config) + prompts.ensure_default_product_suite_prompt(prompt_path) + dialog = ProductSuitePromptDialog( + prompt_path=prompt_path, + base_prompt="卖点", + settings=product_suite.default_suite_settings(), + item_id="", + ) + dialog.show() + self.app.processEvents() + dialog.template_edit.setPlainText( + "未保存修改\n" + prompts.load_default_product_suite_prompt() + ) + self.assertTrue(dialog.is_dirty()) + + with mock.patch.object(dialog, "_unsaved_action", return_value="cancel"): + dialog.reject() + self.assertTrue(dialog.isVisible()) + with mock.patch.object(dialog, "_unsaved_action", return_value="discard"): + dialog.reject() + self.assertFalse(dialog.isVisible()) + + self.assert_removed(temp_dir) + + def test_invalid_product_suite_template_blocks_before_project_or_worker(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + with open(tab.product_suite_prompt_path, "w", encoding="utf-8") as handle: + handle.write("无效模板{未知变量}") + + message = mock.Mock() + with mock.patch.object(tab, "_message", message), mock.patch.object( + tab, + "_start_thread", + ) as start_thread: + result = tab.start_generation(tab._displayed_state) + + self.assertFalse(result) + self.assertIsNone(tab._displayed_state.project_id) + self.assertEqual([], image_studio.list_projects(path=config["db_path"])) + start_thread.assert_not_called() + self.assertEqual("套图提示词模板无效", message.call_args.args[0]) + + self.assert_removed(temp_dir) + + def test_prompt_preview_matches_frozen_generation_job_prompt(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _ = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + state.prompt = "40小时续航,适合通勤" + tab._load_state(state) + + dialog = ProductSuitePromptDialog( + prompt_path=tab.product_suite_prompt_path, + base_prompt=state.prompt, + settings=state.settings, + item_id=state.item_id, + ) + self.addCleanup(dialog.close) + expected = dialog.preview_edit.toPlainText() + + with mock.patch.object(tab, "_start_thread", return_value=object()): + self.assertTrue(tab.start_generation(state)) + self.assertEqual(expected, state.worker.job_specs[0]["prompt"]) + state.worker = None + state.thread = None + + self.assert_removed(temp_dir) + def test_category_rows_are_vertical_with_helpers_and_independent_counters(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 626515f..541b148 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -1,6 +1,7 @@ import os import sys import unittest +from unittest import mock sys.path.insert(0, os.path.dirname(__file__)) @@ -183,6 +184,85 @@ class PromptTests(TempDirMixin, unittest.TestCase): rendered, ) + def test_product_suite_prompt_seed_save_and_restore_are_isolated(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt") + default_text = prompts.load_default_product_suite_prompt() + + self.assertEqual(default_text, prompts.ensure_default_product_suite_prompt(path)) + self.assertEqual(default_text, prompts.load_product_suite_prompt(path)) + + custom_text = "自定义说明\n" + default_text + prompts.save_product_suite_prompt(custom_text, path) + self.assertEqual(custom_text, prompts.ensure_default_product_suite_prompt(path)) + self.assertEqual(custom_text, prompts.load_product_suite_prompt(path)) + + self.assertEqual(default_text, prompts.restore_default_product_suite_prompt(path)) + self.assertEqual(default_text, prompts.load_product_suite_prompt(path)) + + self.assert_removed(temp_dir) + + def test_product_suite_prompt_invalid_user_file_is_preserved(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt") + os.makedirs(os.path.dirname(path), exist_ok=True) + invalid = "坏模板{未知变量}" + with open(path, "w", encoding="utf-8") as handle: + handle.write(invalid) + + with self.assertRaisesRegex(prompts.PromptError, "套图提示词模板无效"): + prompts.ensure_default_product_suite_prompt(path) + with open(path, "r", encoding="utf-8") as handle: + self.assertEqual(invalid, handle.read()) + + self.assert_removed(temp_dir) + + def test_product_suite_prompt_unreadable_utf8_is_not_overwritten(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt") + os.makedirs(os.path.dirname(path), exist_ok=True) + raw = b"\xff\xfe\x00\x80" + with open(path, "wb") as handle: + handle.write(raw) + + with self.assertRaisesRegex(prompts.PromptError, "读取失败"): + prompts.ensure_default_product_suite_prompt(path) + with open(path, "rb") as handle: + self.assertEqual(raw, handle.read()) + + self.assert_removed(temp_dir) + + def test_product_suite_prompt_invalid_packaged_default_does_not_create_file(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt") + with mock.patch.object( + prompts, + "load_default_product_suite_prompt", + side_effect=prompts.PromptError(prompts.PRODUCT_SUITE_DEFAULT_ERROR), + ): + with self.assertRaisesRegex(prompts.PromptError, "内置套图提示词模板无效"): + prompts.ensure_default_product_suite_prompt(path) + self.assertFalse(os.path.exists(path)) + + self.assert_removed(temp_dir) + + def test_product_suite_prompt_atomic_save_failure_keeps_original(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt") + default_text = prompts.ensure_default_product_suite_prompt(path) + custom_text = "自定义说明\n" + default_text + + with mock.patch("app.prompts.os.replace", side_effect=OSError("文件占用")): + with self.assertRaisesRegex(prompts.PromptError, "保存失败"): + prompts.save_product_suite_prompt(custom_text, path) + + self.assertEqual(default_text, prompts.load_product_suite_prompt(path)) + self.assertFalse( + any(name.startswith(".prompt-") for name in os.listdir(os.path.dirname(path))) + ) + + self.assert_removed(temp_dir) + if __name__ == "__main__": unittest.main()