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:
+232
-2
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user