feat: 完成T-302p提示词管理

新增app/prompts.py,支持标题提示词读写、封面模板列表/读取/保存/重命名/删除,以及旧标题、新标题、商品id、店铺变量替换。

Tab②接入标题提示词启动回显和保存,封面模板下拉、新建、保存、另存为、重命名、删除、插入{新标题}与变量预览;本任务仍不调用AI、不写SQLite。

新增tests/test_prompts.py并扩展GUI测试覆盖提示词文件管理和预览;同步任务看板、API、routes、current-state和progress,下一个任务更新为T-303。
This commit is contained in:
chengma
2026-06-27 16:10:50 +08:00
parent 6e690cc02a
commit fe1e0a7d32
9 changed files with 553 additions and 22 deletions
+232 -2
View File
@@ -17,6 +17,7 @@ try:
QFormLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
@@ -77,7 +78,7 @@ QTabBar::tab:hover:!selected {
if QT_IMPORT_ERROR is None:
from . import accounts, appconfig, chrome, db, editor, excel
from . import accounts, appconfig, chrome, db, editor, excel, prompts
from . import config as account_config
@@ -257,6 +258,14 @@ if QT_IMPORT_ERROR is None:
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
return self._account_name(task)
def _account_name(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
@@ -291,26 +300,63 @@ if QT_IMPORT_ERROR is None:
("已更新", "applied"),
]
def __init__(self, parent=None, db_path=None, config=None, status_callback=None):
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH
self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR
self.current_cover_template = None
self.title_prompt_edit = QPlainTextEdit()
self.title_prompt_edit.setObjectName("titlePromptEdit")
self.title_prompt_edit.setPlaceholderText("标题提示词")
self.title_prompt_edit.setPlainText(
prompts.load_title_prompt(self.title_prompt_path)
)
self.save_title_button = QPushButton("保存标题提示词")
self.cover_prompt_edit = QPlainTextEdit()
self.cover_prompt_edit.setObjectName("coverPromptEdit")
self.cover_prompt_edit.setPlaceholderText("封面提示词")
self.cover_template_combo = QComboBox()
self.cover_template_combo.setObjectName("coverTemplateCombo")
self.new_cover_template_button = QPushButton("新建")
self.save_cover_template_button = QPushButton("保存")
self.save_cover_template_as_button = QPushButton("另存为")
self.rename_cover_template_button = QPushButton("重命名")
self.delete_cover_template_button = QPushButton("删除")
self.insert_title_button = QPushButton("插入标题")
self.preview_prompt_button = QPushButton("预览")
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(0, 0, 12, 0)
left_layout.addWidget(QLabel("标题提示词"))
left_layout.addWidget(self.title_prompt_edit, 1)
left_layout.addWidget(self.save_title_button)
left_layout.addWidget(QLabel("封面提示词"))
left_layout.addWidget(self.cover_template_combo)
cover_template_layout = QHBoxLayout()
cover_template_layout.addWidget(self.new_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_as_button)
cover_template_layout.addWidget(self.rename_cover_template_button)
cover_template_layout.addWidget(self.delete_cover_template_button)
left_layout.addLayout(cover_template_layout)
left_layout.addWidget(self.cover_prompt_edit, 2)
cover_action_layout = QHBoxLayout()
cover_action_layout.addWidget(self.insert_title_button)
cover_action_layout.addWidget(self.preview_prompt_button)
left_layout.addLayout(cover_action_layout)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("batchFilter")
@@ -363,13 +409,197 @@ if QT_IMPORT_ERROR is None:
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.save_title_button.clicked.connect(self.save_title_prompt)
self.cover_template_combo.currentIndexChanged.connect(self.load_selected_cover_template)
self.new_cover_template_button.clicked.connect(self.new_cover_template)
self.save_cover_template_button.clicked.connect(self.save_cover_template)
self.save_cover_template_as_button.clicked.connect(self.save_cover_template_as)
self.rename_cover_template_button.clicked.connect(self.rename_cover_template)
self.delete_cover_template_button.clicked.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.refresh_cover_templates()
self.refresh_tasks()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def save_title_prompt(self, checked=False):
try:
prompts.save_title_prompt(
self.title_prompt_edit.toPlainText(),
self.title_prompt_path,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status("标题提示词已保存")
def refresh_cover_templates(self, selected=None):
try:
template_names = prompts.list_cover_templates(self.cover_prompts_dir)
except Exception as exc:
template_names = []
self._show_prompt_error(exc)
current = selected if selected is not None else self.current_cover_template
self.cover_template_combo.blockSignals(True)
self.cover_template_combo.clear()
if template_names:
for name in template_names:
self.cover_template_combo.addItem(name, name)
index = self.cover_template_combo.findData(current)
self.cover_template_combo.setCurrentIndex(index if index >= 0 else 0)
else:
self.cover_template_combo.addItem("默认", None)
self.cover_template_combo.setCurrentIndex(0)
self.cover_template_combo.blockSignals(False)
self.load_selected_cover_template()
def load_selected_cover_template(self, index=None):
name = self.cover_template_combo.currentData()
self.current_cover_template = name
if name is None:
self.cover_prompt_edit.setPlainText("")
return
try:
self.cover_prompt_edit.setPlainText(
prompts.load_cover_template(name, self.cover_prompts_dir)
)
except Exception as exc:
self.cover_prompt_edit.setPlainText("")
self._show_prompt_error(exc)
def new_cover_template(self, checked=False):
name = self._ask_template_name("新建封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(name, "", self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已新建:{name}")
def save_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self.save_cover_template_as()
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status(f"封面提示词模板已保存:{name}")
def save_cover_template_as(self, checked=False):
name = self._ask_template_name("另存封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已另存为:{name}")
def rename_cover_template(self, checked=False):
old_name = self.current_cover_template
if old_name is None:
self._set_status("没有可重命名的封面提示词模板")
return
new_name = self._ask_template_name("重命名封面提示词模板", text=old_name)
if not new_name or new_name == old_name:
return
try:
prompts.rename_cover_template(old_name, new_name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=new_name)
self._set_status(f"封面提示词模板已重命名:{new_name}")
def delete_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self._set_status("没有可删除的封面提示词模板")
return
choice = QMessageBox.question(
self,
"删除封面提示词模板",
f"确定删除「{name}」吗?",
)
if choice != QMessageBox.Yes:
return
try:
prompts.delete_cover_template(name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates()
self._set_status(f"封面提示词模板已删除:{name}")
def insert_title_placeholder(self, checked=False):
self.cover_prompt_edit.insertPlainText("{新标题}")
def preview_cover_prompt(self, checked=False):
task = self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
rendered = prompts.render_prompt(
self.cover_prompt_edit.toPlainText(),
self._prompt_context(task),
)
QMessageBox.information(self, "封面提示词预览", rendered)
self._set_status("封面提示词预览已生成")
def _selected_task(self):
index = self.task_table.currentIndex()
if index.isValid():
return self.model.task_at(index.row())
if self.model.rowCount() > 0:
return self.model.task_at(0)
return None
def _prompt_context(self, task):
return {
"old_title": task.old_title,
"new_title": task.new_title,
"item_id": task.item_id,
"account_name": self.model.account_name_for(task),
"alias": task.alias,
}
def _ask_template_name(self, title, text=""):
value, ok = QInputDialog.getText(
self,
title,
"模板名",
QLineEdit.Normal,
text,
)
if not ok:
return None
return str(value).strip()
def _show_prompt_error(self, error):
message = str(error)
QMessageBox.warning(self, "提示词管理", message)
self._set_status(message)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
+133
View File
@@ -0,0 +1,133 @@
"""Prompt file management and variable rendering."""
from __future__ import annotations
import os
TITLE_PROMPT_PATH = "title_prompt.txt"
COVER_PROMPTS_DIR = os.path.join("prompts", "cover")
TEMPLATE_EXT = ".txt"
INVALID_NAME_CHARS = set('\\/:*?"<>|')
class PromptError(RuntimeError):
"""Raised when prompt files or template names are invalid."""
def load_title_prompt(path=TITLE_PROMPT_PATH) -> str:
"""Load the title prompt text. Missing file means an empty prompt."""
if not os.path.exists(path):
return ""
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def save_title_prompt(text, path=TITLE_PROMPT_PATH) -> None:
"""Save the title prompt text as UTF-8."""
directory = os.path.dirname(os.path.abspath(path))
if directory:
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(str(text or ""))
def list_cover_templates(directory=COVER_PROMPTS_DIR):
"""Return cover template names sorted by display name."""
if not os.path.isdir(directory):
return []
names = []
for filename in os.listdir(directory):
if filename.lower().endswith(TEMPLATE_EXT):
names.append(filename[: -len(TEMPLATE_EXT)])
return sorted(names, key=str.casefold)
def load_cover_template(name, directory=COVER_PROMPTS_DIR) -> str:
"""Load one cover prompt template."""
path = _template_path(name, directory)
if not os.path.exists(path):
raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}")
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def save_cover_template(name, text, directory=COVER_PROMPTS_DIR) -> None:
"""Save one cover prompt template as UTF-8."""
path = _template_path(name, directory)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(str(text or ""))
def rename_cover_template(old, new, directory=COVER_PROMPTS_DIR) -> None:
"""Rename a cover prompt template with duplicate-name protection."""
old_path = _template_path(old, directory)
new_path = _template_path(new, directory)
if not os.path.exists(old_path):
raise PromptError(f"封面提示词模板不存在: {_normalize_name(old)}")
if os.path.exists(new_path):
raise PromptError(f"封面提示词模板已存在: {_normalize_name(new)}")
os.makedirs(os.path.dirname(new_path), exist_ok=True)
os.replace(old_path, new_path)
def delete_cover_template(name, directory=COVER_PROMPTS_DIR) -> None:
"""Delete one cover prompt template."""
path = _template_path(name, directory)
if not os.path.exists(path):
raise PromptError(f"封面提示词模板不存在: {_normalize_name(name)}")
os.remove(path)
def render_prompt(template_text, task) -> str:
"""Render known task variables in a prompt template."""
values = {
"旧标题": _task_value(task, "old_title", "旧标题"),
"新标题": _task_value(task, "new_title", "新标题"),
"商品id": _task_value(task, "item_id", "商品id", "商品ID"),
"店铺": _task_value(task, "shop", "account_name", "店铺", "alias"),
}
rendered = str(template_text or "")
for name, value in values.items():
rendered = rendered.replace("{" + name + "}", value)
return rendered
def _task_value(task, *names) -> str:
for name in names:
value = None
if isinstance(task, dict):
value = task.get(name)
else:
value = getattr(task, name, None)
if value is not None:
return str(value)
return ""
def _template_path(name, directory) -> str:
normalized = _normalize_name(name)
return os.path.abspath(os.path.join(directory, normalized + TEMPLATE_EXT))
def _normalize_name(name) -> str:
value = str(name or "").strip()
if value.lower().endswith(TEMPLATE_EXT):
value = value[: -len(TEMPLATE_EXT)]
value = value.strip()
if not value:
raise PromptError("封面提示词模板名不能为空")
if value in {".", ".."} or any(char in INVALID_NAME_CHARS for char in value):
raise PromptError(f"封面提示词模板名非法: {value}")
if os.path.basename(value) != value:
raise PromptError(f"封面提示词模板名非法: {value}")
return value