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)
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 @@
套图名称:{套图名称}{补充描述}
平台:{平台};国家地区:{国家地区};输出语言:{输出语言}。
{尺寸与长图规则}
{禁用内容规则}
{价格信息规则}
{尺码信息规则}
{参考图规则}
商品卖点与要求:
{商品卖点与要求}
本次生成比例:{图片比例}。请严格按此比例输出,不能改成其他长宽比。
+1
View File
@@ -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
+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,
)
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
View File
@@ -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
View File
@@ -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 (