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
+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。")