feat(product-suite): add prompt template settings

This commit is contained in:
chengma
2026-07-16 16:18:11 +08:00
parent 613950c9f3
commit eea3fdd0a9
14 changed files with 1017 additions and 35 deletions
+4
View File
@@ -265,6 +265,10 @@ def image_studio_prompts_dir(config=None) -> str:
return data_path("prompts", "image_studio", config=config) 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: def diagnostic_log_dir(config=None) -> str:
return data_path("logs", config=config) return data_path("logs", config=config)
@@ -0,0 +1 @@
"""Bundled product-suite prompt resources."""
@@ -0,0 +1,10 @@
套图名称:{套图名称}{补充描述}
平台:{平台};国家地区:{国家地区};输出语言:{输出语言}。
{尺寸与长图规则}
{禁用内容规则}
{价格信息规则}
{尺码信息规则}
{参考图规则}
商品卖点与要求:
{商品卖点与要求}
本次生成比例:{图片比例}。请严格按此比例输出,不能改成其他长宽比。
+1
View File
@@ -39,6 +39,7 @@ if QT_IMPORT_ERROR is None:
from .tabs.collect import CollectTab from .tabs.collect import CollectTab
from .tabs.generate import GenerateTab from .tabs.generate import GenerateTab
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
from .product_suite_prompt_dialog import ProductSuitePromptDialog
from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab
from .tabs.settings import SettingsTab from .tabs.settings import SettingsTab
from .main_window import MainWindow from .main_window import MainWindow
+318
View File
@@ -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()
+65 -13
View File
@@ -40,9 +40,18 @@ from PySide6.QtWidgets import (
QWidget, 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 .. import file_manager
from ..image_preview import ImagePreviewDialog from ..image_preview import ImagePreviewDialog
from ..product_suite_prompt_dialog import ProductSuitePromptDialog
from ..widgets import _emit_status, run_worker from ..widgets import _emit_status, run_worker
from ..workers import ( from ..workers import (
ImageStudioDownloadOriginalWorker, ImageStudioDownloadOriginalWorker,
@@ -57,13 +66,6 @@ ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
_PRODUCT_SUITE_THREAD_REFS = {} _PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
FIXED_CATEGORY_HELPERS = {
"白底图": "白底主图,多角度呈现商品细节",
"场景图": "生活化场景展示商品使用方式",
"卖点图": "突出核心卖点和差异化优势",
}
def _asset_usable(asset): def _asset_usable(asset):
path = str(getattr(asset, "local_path", "") or "") path = str(getattr(asset, "local_path", "") or "")
return ( return (
@@ -663,6 +665,7 @@ class ProductSuiteTab(QWidget):
self.db_path = db_path or appconfig.db_path(self.config) 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.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.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.status_callback = status_callback
self.accounts = [] self.accounts = []
self._states = {} self._states = {}
@@ -673,6 +676,12 @@ class ProductSuiteTab(QWidget):
self._original_list_context = None self._original_list_context = None
self._loading = False self._loading = False
self._result_refresh_pending = 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._build_ui()
self._connect_signals() self._connect_signals()
@@ -684,6 +693,8 @@ class ProductSuiteTab(QWidget):
self.elapsed_timer.setInterval(1000) self.elapsed_timer.setInterval(1000)
self.elapsed_timer.timeout.connect(self._refresh_elapsed) self.elapsed_timer.timeout.connect(self._refresh_elapsed)
self.elapsed_timer.start() self.elapsed_timer.start()
if self._prompt_template_init_error:
self._status(self._prompt_template_init_error, "danger")
def _build_ui(self): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
@@ -956,17 +967,23 @@ class ProductSuiteTab(QWidget):
layout = QVBoxLayout(frame) layout = QVBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
title_row = QHBoxLayout() title_row = QHBoxLayout()
self.prompt_title_layout = title_row
title = QLabel("商品卖点与要求") title = QLabel("商品卖点与要求")
self.prompt_title_label = title
title.setStyleSheet("font-weight: 600;") title.setStyleSheet("font-weight: 600;")
title_row.addWidget(title) 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 = QPushButton("取消")
self.ai_cancel_button.setObjectName("suiteAiCancelButton") self.ai_cancel_button.setObjectName("suiteAiCancelButton")
self.ai_cancel_button.hide() self.ai_cancel_button.hide()
title_row.addWidget(self.ai_cancel_button) title_row.addWidget(self.ai_cancel_button)
self.ai_write_button = QPushButton("AI 帮写") title_row.addStretch(1)
self.ai_write_button.setObjectName("suiteAiWriteButton") self.prompt_settings_button = QPushButton("提示词设置")
title_row.addWidget(self.ai_write_button) self.prompt_settings_button.setObjectName("suitePromptSettingsButton")
self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词")
title_row.addWidget(self.prompt_settings_button)
layout.addLayout(title_row) layout.addLayout(title_row)
self.prompt_edit = QPlainTextEdit() self.prompt_edit = QPlainTextEdit()
self.prompt_edit.setObjectName("suitePromptEdit") self.prompt_edit.setObjectName("suitePromptEdit")
@@ -1098,6 +1115,7 @@ class ProductSuiteTab(QWidget):
self.prompt_edit.textChanged.connect(self._on_prompt_changed) self.prompt_edit.textChanged.connect(self._on_prompt_changed)
self.ai_write_button.clicked.connect(self.start_ai_write) self.ai_write_button.clicked.connect(self.start_ai_write)
self.ai_cancel_button.clicked.connect(self.cancel_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.add_category_button.clicked.connect(self.add_custom_category)
self.custom_category_edit.returnPressed.connect(self._commit_custom_category) self.custom_category_edit.returnPressed.connect(self._commit_custom_category)
self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit) self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit)
@@ -1638,6 +1656,27 @@ class ProductSuiteTab(QWidget):
state = self._displayed_state state = self._displayed_state
state.prompt = self.prompt_edit.toPlainText() 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): def _update_context_actions(self, state):
is_draft = self._is_draft_state(state) is_draft = self._is_draft_state(state)
self.pull_button.setEnabled( self.pull_button.setEnabled(
@@ -2093,7 +2132,7 @@ class ProductSuiteTab(QWidget):
row = SuiteCategoryRow( row = SuiteCategoryRow(
name, name,
categories.get(name, 0), categories.get(name, 0),
helper=FIXED_CATEGORY_HELPERS.get(name, ""), helper=product_suite.category_helper(name),
custom=custom, custom=custom,
) )
row.countChangeRequested.connect(self.change_category_count) row.countChangeRequested.connect(self.change_category_count)
@@ -2327,6 +2366,18 @@ class ProductSuiteTab(QWidget):
return False return False
if state is self._displayed_state: if state is self._displayed_state:
self._save_controls_to_state(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: if self._ensure_project_for_local_work(state) is None:
return False return False
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
@@ -2341,6 +2392,7 @@ class ProductSuiteTab(QWidget):
state.prompt, state.prompt,
state.settings, state.settings,
state.item_id or "未绑定商品", state.item_id or "未绑定商品",
template_text=template_text,
)) ))
if not specs: if not specs:
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
+190 -15
View File
@@ -3,9 +3,15 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
import re
FIXED_CATEGORIES = ("白底图", "场景图", "卖点图") FIXED_CATEGORIES = ("白底图", "场景图", "卖点图")
FIXED_CATEGORY_HELPERS = {
"白底图": "白底主图,多角度呈现商品细节",
"场景图": "生活化场景展示商品使用方式",
"卖点图": "突出核心卖点和差异化优势",
}
DEFAULT_CATEGORY_COUNTS = OrderedDict( DEFAULT_CATEGORY_COUNTS = OrderedDict(
(("白底图", 1), ("场景图", 2), ("卖点图", 2)) (("白底图", 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") LAST_SETTING_KEYS = ("platform", "country", "language", "ratio")
MAX_CATEGORY_NAME_LENGTH = 10 MAX_CATEGORY_NAME_LENGTH = 10
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16 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(): def default_suite_settings():
@@ -89,6 +149,15 @@ def category_order(settings):
return list(FIXED_CATEGORIES) + custom 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): def suite_total_count(settings, image_count):
normalized = normalize_suite_settings(settings) normalized = normalize_suite_settings(settings)
categories = normalized["categories"] 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)) 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): def product_suite_prompt_errors(template_text):
normalized = normalize_suite_settings(settings) text = str(template_text or "")
context = [ errors = []
"生成一张电商商品套图。", if not text.strip():
"平台:%s" % normalized["platform"], return ["套图提示词模板不能为空"]
"国家地区:%s" % normalized["country"],
"输出语言:%s" % normalized["language"], matches = list(_PRODUCT_SUITE_PLACEHOLDER_RE.finditer(text))
"图片比例:%s" % normalized["ratio"], remainder = _PRODUCT_SUITE_PLACEHOLDER_RE.sub("", text)
"套图分类:%s" % str(category), if "{" in remainder or "}" in remainder:
"商品ID:%s" % str(item_id or ""), errors.append("模板包含未闭合花括号或不支持的字面花括号")
"当前主参考图序号:%d" % max(1, int(source_index or 1)),
"商品卖点与要求:%s" % str(base_prompt or "").strip(), 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 []) assets = list(source_assets or [])
if not assets: if not assets:
return [] return []
@@ -143,6 +317,7 @@ def build_job_specs(source_assets, base_prompt, settings, item_id):
category, category,
item_id, item_id,
source_index=source_index, source_index=source_index,
template_text=template_text,
), ),
} }
) )
+112
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
from importlib import resources from importlib import resources
from . import appconfig from . import appconfig
@@ -11,10 +12,14 @@ TITLE_PROMPT_PATH = appconfig.title_prompt_path()
TITLE_TEMPLATES_DIR = appconfig.title_templates_dir() TITLE_TEMPLATES_DIR = appconfig.title_templates_dir()
COVER_PROMPTS_DIR = appconfig.cover_prompts_dir() COVER_PROMPTS_DIR = appconfig.cover_prompts_dir()
IMAGE_STUDIO_PROMPTS_DIR = appconfig.image_studio_prompts_dir() IMAGE_STUDIO_PROMPTS_DIR = appconfig.image_studio_prompts_dir()
PRODUCT_SUITE_PROMPT_PATH = appconfig.product_suite_prompt_path()
TEMPLATE_EXT = ".txt" TEMPLATE_EXT = ".txt"
INVALID_NAME_CHARS = set('\\/:*?"<>|') INVALID_NAME_CHARS = set('\\/:*?"<>|')
DEFAULT_PROMPTS_PACKAGE = "app.default_prompts" DEFAULT_PROMPTS_PACKAGE = "app.default_prompts"
DEFAULT_COVER_PROMPTS_PACKAGE = "app.default_prompts.cover" 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): class PromptError(RuntimeError):
@@ -69,6 +74,74 @@ def ensure_default_prompts(
save_cover_template(name, text, cover_prompts_dir) 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): def list_templates(directory):
"""Return prompt template names sorted by display name.""" """Return prompt template names sorted by display name."""
@@ -273,6 +346,45 @@ def _has_non_empty_file(path) -> bool:
return False 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: def _read_default_prompt_text(filename) -> str:
try: try:
return ( return (
+3 -1
View File
@@ -430,7 +430,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。
- 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 任一组件生成后 `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”。 - 并发数、重试、分辨率、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`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - `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_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。 - T-636 起,`image_studio_projects` 增加 `binding_state`(`draft` / `bound`)和稳定 `storage_key`。既有项目迁移为 `bound`,并以原 `item_id` 回填 `storage_key`;项目目录改用 `storage_key`,因此临时草稿绑定正式商品 ID 后不移动目录、不改写已有资产路径。草稿内部使用 `draft_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
- ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 - ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
@@ -441,6 +442,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,左侧模板行提供下拉、新建、保存模板、另存为、重命名、删除;模板只负责被用户选中后载入编辑框,或把当前编辑框内容保存为命名模板,不改变生成读取路径,也不在启动时覆盖 `title_prompt.txt`。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。 - **标题提示词**:`data/title_prompt.txt` 是当前工作文本,「保存标题提示词」写入该文件;软件启动时加载该文件回显到输入框(缺失则空)。标题命名模板另存于 `data/prompts/title/*.txt`,左侧模板行提供下拉、新建、保存模板、另存为、重命名、删除;模板只负责被用户选中后载入编辑框,或把当前编辑框内容保存为命名模板,不改变生成读取路径,也不在启动时覆盖 `title_prompt.txt`。T-549 后标题提示词支持 `{旧标题}` 占位符:若提示词含 `{旧标题}`,生成前替换为该任务旧标题且不再自动追加旧标题块;若不含,则保持旧行为自动追加“旧标题:...”块。两种情况都会保留“请只返回新标题,不要解释。”输出约束。
- **封面提示词**:多模板。左侧模板行提供下拉(读 `data/prompts/cover/*.txt`)、新建、保存模板、模板操作(另存为/重命名/删除);重名校验、删除二次确认、删空给默认。 - **封面提示词**:多模板。左侧模板行提供下拉(读 `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 的内容。 - **变量**:标题提示词本阶段只支持 `{旧标题}`,左侧按钮「插入旧标题」在标题提示词光标处插入 `{旧标题}`。封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在封面提示词光标处插入 `{新标题}`;「预览」= 用某条任务的值替换封面变量后展示,确认实际发送给 AI 的内容。
### 6.3 应用更新(③ Tab) ### 6.3 应用更新(③ Tab)
+5 -1
View File
@@ -1,7 +1,7 @@
--- ---
id: T-637 id: T-637
title: 商品套图提示词设置弹窗与实时最终预览 title: 商品套图提示词设置弹窗与实时最终预览
status: TODO status: DONE
phase: 7 phase: 7
deps: [T-634, T-636] deps: [T-634, T-636]
created: 2026-07-16 created: 2026-07-16
@@ -231,3 +231,7 @@ git diff --check
- 2026-07-16 补:按用户要求把默认模板行序对齐 obsidian《虾皮圈电商图生成器提示词组装分析》第三节的组装顺序(用户接受的结构)——强制规则从「文末代码追加」改为四个必需只读占位符(尺寸与长图/禁用内容/价格/尺码)内联在平台行与参考图规则之间;比例强调移至末行;商品一致性与不编造并入 `{参考图规则}` 渲染文本;占位符总数 13→17,必需 8→12。 - 2026-07-16 补:按用户要求把默认模板行序对齐 obsidian《虾皮圈电商图生成器提示词组装分析》第三节的组装顺序(用户接受的结构)——强制规则从「文末代码追加」改为四个必需只读占位符(尺寸与长图/禁用内容/价格/尺码)内联在平台行与参考图规则之间;比例强调移至末行;商品一致性与不编造并入 `{参考图规则}` 渲染文本;占位符总数 13→17,必需 8→12。
- 2026-07-16 补:补齐首次初始化和重复占位符语义——内置模板首次复制前同样必须校验,失败时不创建用户文件并阻断生成;重复 `{差异化要求}`的每次出现都必须独占一行,总数为 1 时定点删除全部对应行;历史执行记录标注已被后续结论覆盖。 - 2026-07-16 补:补齐首次初始化和重复占位符语义——内置模板首次复制前同样必须校验,失败时不创建用户文件并阻断生成;重复 `{差异化要求}`的每次出现都必须独占一行,总数为 1 时定点删除全部对应行;历史执行记录标注已被后续结论覆盖。
- 2026-07-16 简:按用户要求简化模板结构——删除 `{分类要求}`、`{差异化要求}`、`{分类序号}`、`{分类总数}`四个占位符及默认模板中的「商品ID:{商品ID}」行;`{套图分类}`改名为 `{套图名称}`并新增 `{补充描述}`(白底图/场景图/卖点图有说明、自定义分类为空);连带去掉弹窗「预览第 X 张」步进器和同类多张差异化机制(第一版沿用源项目依赖模型随机性区分同类多张);占位符 17→14、必需 12→9;`{商品ID}`、`{主参考图序号}`降为可选可插入变量。 - 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` 单测失败,未纳入、未修改该用户改动。
+3
View File
@@ -32,6 +32,9 @@ class PackagingTests(unittest.TestCase):
self.assertIn("datas=default_prompt_datas", normalized) self.assertIn("datas=default_prompt_datas", normalized)
self.assertIn("COLLECT(", spec) self.assertIn("COLLECT(", spec)
self.assertIn('name="cmshopee"', normalized) 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): def test_build_script_blocks_user_data_in_release_output(self):
script = self.read_text("scripts/build_exe.ps1") script = self.read_text("scripts/build_exe.ps1")
+76 -3
View File
@@ -5,7 +5,7 @@ from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from app import product_suite from app import product_suite, prompts
class ProductSuiteTests(unittest.TestCase): class ProductSuiteTests(unittest.TestCase):
@@ -88,6 +88,7 @@ class ProductSuiteTests(unittest.TestCase):
"40小时续航,适合通勤", "40小时续航,适合通勤",
settings, settings,
"51100639510", "51100639510",
template_text=prompts.load_default_product_suite_prompt(),
) )
self.assertEqual(3, len(specs)) self.assertEqual(3, len(specs))
@@ -97,9 +98,81 @@ class ProductSuiteTests(unittest.TestCase):
self.assertIn("平台:Shopee", spec["prompt"]) self.assertIn("平台:Shopee", spec["prompt"])
self.assertIn("国家地区:中国台湾", spec["prompt"]) self.assertIn("国家地区:中国台湾", spec["prompt"])
self.assertIn("输出语言:繁体中文", spec["prompt"]) self.assertIn("输出语言:繁体中文", spec["prompt"])
self.assertIn("图片比例:4:3", spec["prompt"]) self.assertIn("本次生成比例:4:3", spec["prompt"])
self.assertIn("商品ID:51100639510", spec["prompt"]) self.assertNotIn("商品ID:51100639510", spec["prompt"])
self.assertIn("40小时续航", 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__": if __name__ == "__main__":
+149 -2
View File
@@ -8,14 +8,14 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin 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 from app import gui
if gui.QT_IMPORT_ERROR is not None: if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QMimeData, Qt, QUrl 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.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton
@@ -26,6 +26,7 @@ from app.gui.tabs.product_suite import (
ProductSuiteTab, ProductSuiteTab,
SuiteResultCard, SuiteResultCard,
) )
from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog
class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
@@ -150,6 +151,19 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("打开结果文件夹", tab.open_folder_button.text()) self.assertEqual("打开结果文件夹", tab.open_folder_button.text())
self.assertEqual("合计 5 张", tab.category_total_label.text()) self.assertEqual("合计 5 张", tab.category_total_label.text())
self.assertEqual("生成套图(5)", tab.generate_button.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( visible_text = " ".join(
[widget.text() for widget in tab.findChildren(QLabel)] [widget.text() for widget in tab.findChildren(QLabel)]
@@ -171,6 +185,139 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_category_rows_are_vertical_with_helpers_and_independent_counters(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir) config = self._config(temp_dir)
+80
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
import unittest import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -183,6 +184,85 @@ class PromptTests(TempDirMixin, unittest.TestCase):
rendered, 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__": if __name__ == "__main__":
unittest.main() unittest.main()