feat(product-suite): add prompt template settings
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Bundled product-suite prompt resources."""
|
||||
@@ -0,0 +1,10 @@
|
||||
套图名称:{套图名称}{补充描述}
|
||||
平台:{平台};国家地区:{国家地区};输出语言:{输出语言}。
|
||||
{尺寸与长图规则}
|
||||
{禁用内容规则}
|
||||
{价格信息规则}
|
||||
{尺码信息规则}
|
||||
{参考图规则}
|
||||
商品卖点与要求:
|
||||
{商品卖点与要求}
|
||||
本次生成比例:{图片比例}。请严格按此比例输出,不能改成其他长宽比。
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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。")
|
||||
|
||||
+190
-15
@@ -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,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
+112
@@ -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 (
|
||||
|
||||
@@ -430,7 +430,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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_<uuid>` 作为仅数据库可见的非空 `item_id`,GUI 输入框始终保持空白,用户日志和 cmhub 提示词只使用“临时草稿”或“未绑定商品”,不得暴露该内部值。
|
||||
- ⑥已选账号但未填写商品 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/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)
|
||||
|
||||
+5
-1
@@ -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` 单测失败,未纳入、未修改该用户改动。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user