Files
cmshoppe/app/gui/tabs/generate.py
T

1058 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tab 2: AI generation UI."""
from __future__ import annotations
from ..models import GenerateTaskTableModel
from ..widgets import *
from ..workers import GenerateWorker as _RealGenerateWorker
def GenerateWorker(*args, **kwargs):
return _call_package_attr("GenerateWorker", _RealGenerateWorker, *args, **kwargs)
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
STATUS_FILTERS = [
("全部状态", "all"),
("待生成", "to_generate"),
("已生成", "generated"),
("失败", "failed"),
("略过", "skipped"),
("已更新", "applied"),
]
RESET_MODE_LABELS = {
"title": "标题",
"cover": "封面",
"all": "标题和封面",
}
def __init__(
self,
parent=None,
db_path=None,
config=None,
config_path=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
open_accounts_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
self.current_cover_template = None
self.generate_worker = None
self.generate_thread = 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.cover_template_actions_button = QPushButton("模板操作")
self.cover_template_actions_button.setObjectName("coverTemplateActionsButton")
self.cover_template_actions_menu = QMenu(self)
self.save_cover_template_as_action = self.cover_template_actions_menu.addAction("另存为")
self.save_cover_template_as_action.setObjectName("saveCoverTemplateAsAction")
self.rename_cover_template_action = self.cover_template_actions_menu.addAction("重命名")
self.rename_cover_template_action.setObjectName("renameCoverTemplateAction")
self.delete_cover_template_action = self.cover_template_actions_menu.addAction("删除")
self.delete_cover_template_action.setObjectName("deleteCoverTemplateAction")
self.cover_template_actions_button.setMenu(self.cover_template_actions_menu)
self.insert_title_button = QPushButton("插入标题")
self.preview_prompt_button = QPushButton("预览")
self.generate_button = QPushButton("开始生成")
self.stop_generate_button = QPushButton("停止")
self.reset_generate_button = QPushButton("重置生成结果")
self.reset_generate_button.setObjectName("resetGenerateButton")
self.stop_generate_button.setEnabled(False)
ai_settings = appconfig.ai_config(self.config)
self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)")
self.generate_cover_checkbox.setObjectName("generateCoverCheckbox")
self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型")
self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False)))
self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0")
self.title_progress_label = QLabel("标题 0/0")
self.title_progress_label.setObjectName("generateTitleProgressLabel")
self.title_progress_bar = QProgressBar()
self.title_progress_bar.setObjectName("generateTitleProgressBar")
self.title_progress_bar.setTextVisible(False)
self.title_progress_bar.setRange(0, 1)
self.title_progress_bar.setValue(0)
self.cover_progress_label = QLabel("图片 0/0")
self.cover_progress_label.setObjectName("generateCoverProgressLabel")
self.cover_progress_bar = QProgressBar()
self.cover_progress_bar.setObjectName("generateCoverProgressBar")
self.cover_progress_bar.setTextVisible(False)
self.cover_progress_bar.setRange(0, 1)
self.cover_progress_bar.setValue(0)
self.failed_progress_label = QLabel("失败 0")
self.failed_progress_label.setObjectName("generateFailedProgressLabel")
self.cmhub_balance_label = QLabel("cmhub余额:未获取")
self.cmhub_balance_label.setObjectName("generateCmhubBalanceLabel")
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.cover_template_actions_button)
cover_template_layout.addStretch(1)
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")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("shopFilter")
self.item_filter = QLineEdit()
self.item_filter.setObjectName("generateItemFilter")
self.item_filter.setPlaceholderText("商品ID")
self.status_filter = QComboBox()
self.status_filter.setObjectName("statusFilter")
for label, value in self.STATUS_FILTERS:
self.status_filter.addItem(label, value)
self.refresh_button = QPushButton("刷新")
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("批次"))
filter_layout.addWidget(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1)
filter_layout.addWidget(QLabel("商品ID"))
filter_layout.addWidget(self.item_filter, 1)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.batch_progress_label = _build_batch_progress_overview("generateBatchProgressOverview")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("generateEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
self.task_table = QTableView()
self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("generateRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("AI生成运行日志")
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 0, 0, 0)
right_layout.addLayout(filter_layout)
right_layout.addWidget(self.summary_label)
right_layout.addWidget(self.batch_progress_label)
right_layout.addWidget(self.empty_state_card)
right_layout.addWidget(self.task_table, 1)
right_layout.addWidget(QLabel("AI生成运行日志"))
right_layout.addWidget(self.run_log_view)
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.addWidget(left_panel)
self.splitter.addWidget(right_panel)
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 3)
self.splitter.setSizes([280, 860])
title_progress_layout = QHBoxLayout()
title_progress_layout.addWidget(self.title_progress_label)
title_progress_layout.addWidget(self.title_progress_bar, 1)
cover_progress_layout = QHBoxLayout()
cover_progress_layout.addWidget(self.cover_progress_label)
cover_progress_layout.addWidget(self.cover_progress_bar, 1)
cover_progress_layout.addWidget(self.failed_progress_label)
cover_progress_layout.addWidget(self.cmhub_balance_label)
progress_layout = QVBoxLayout()
progress_layout.addLayout(title_progress_layout)
progress_layout.addLayout(cover_progress_layout)
button_layout = QHBoxLayout()
button_layout.addStretch(1)
button_layout.addWidget(self.generate_button)
button_layout.addWidget(self.stop_generate_button)
button_layout.addWidget(self.reset_generate_button)
bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.generate_cover_checkbox)
bottom_layout.addLayout(progress_layout, 1)
bottom_layout.addLayout(button_layout)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addWidget(self.splitter, 1)
layout.addLayout(bottom_layout)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.item_filter.textChanged.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_action.triggered.connect(self.save_cover_template_as)
self.rename_cover_template_action.triggered.connect(self.rename_cover_template)
self.delete_cover_template_action.triggered.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.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled)
self.generate_button.clicked.connect(self.start_generate)
self.stop_generate_button.clicked.connect(self.stop_generate)
self.reset_generate_button.clicked.connect(self.reset_generated_result)
self.task_table.doubleClicked.connect(self.show_task_images)
self.refresh_cover_templates()
self.refresh_tasks()
self._show_current_run_log_empty()
self._update_cmhub_balance_label({})
def _set_status(self, message, level=None):
_emit_status(self.status_callback, message, level=level)
def _on_generate_log(self, message):
self._append_generate_log(message)
self._set_status(message)
def _append_generate_log(self, message):
self.run_log_view.appendPlainText(str(message))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _show_current_run_log_empty(self):
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
def _reset_generate_run_log(self, tasks, generate_cover):
self.run_log_view.clear()
cover_text = "是" if generate_cover else "否"
self._append_generate_log(
f"本轮AI生成开始:任务 {len(tasks)} 条,生成封面:{cover_text}"
)
def _on_generate_cover_toggled(self, checked):
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
if self._save_generate_cover_setting(show_status=True):
self.refresh_tasks()
return
self.generate_cover_checkbox.blockSignals(True)
self.generate_cover_checkbox.setChecked(previous)
self.generate_cover_checkbox.blockSignals(False)
def _save_generate_cover_setting(self, show_status=True):
generate_cover = bool(self.generate_cover_checkbox.isChecked())
ai_settings = appconfig.ai_config(self.config)
ai_settings["generate_cover"] = generate_cover
payload = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
payload["ai"] = ai_settings
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"生成封面开关保存失败:{exc}")
return False
internal = {
key: value
for key, value in self.config.items()
if key in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
self.config.clear()
self.config.update(saved)
self.config.update(internal)
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片"
self._set_status(f"AI生成设置已保存:{mode}")
return True
def _load_latest_generate_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in reversed(events)
]
self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
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 start_generate(self, checked=False):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行...")
return
if not self._save_generate_cover_setting(show_status=False):
return
generate_cover = bool(self.generate_cover_checkbox.isChecked())
tasks = [
task for task in self.model.tasks
if ai.is_generatable_task(task, generate_cover=generate_cover)
]
if not tasks:
self._set_status(
"当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或勾选“生成封面图片(成本较高)”补封面"
)
return
component_totals = ai.generation_component_totals(
tasks,
generate_cover=generate_cover,
)
prompt_values = {
"title": self.title_prompt_edit.toPlainText(),
"cover": self.cover_prompt_edit.toPlainText(),
}
worker = GenerateWorker(
tasks,
prompt_values,
db_path=self.db_path,
config=self.config,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.progress.connect(self._on_generate_progress)
worker.row_updated.connect(self._on_generate_row_updated)
worker.log.connect(self._on_generate_log)
worker.failed.connect(self._on_generate_failed)
worker.finished.connect(self._on_generate_finished)
worker.cancelled.connect(self._on_generate_cancelled)
self._reset_generate_run_log(tasks, generate_cover)
self._reset_cmhub_balance_label()
thread = run_worker(worker, thread_name="GenerateWorker", start=False)
thread.finished.connect(lambda: self._forget_generate_thread(thread))
self.generate_worker = worker
self.generate_thread = thread
self._set_generate_running(True)
self._update_generate_progress(
{
"total": len(tasks),
"title_total": component_totals["title_total"],
"title_done": 0,
"cover_done": 0,
"cover_total": component_totals["cover_total"] if generate_cover else 0,
"generated_done": 0,
"failed": 0,
"generate_cover": generate_cover,
}
)
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
thread.start()
def stop_generate(self, checked=False):
if self.generate_worker is not None:
self.generate_worker.cancel()
self._append_generate_log("[停止] 已收到停止请求,当前正在运行的任务结束后停止")
self._set_status("正在停止 AI 生成...")
def reset_generated_result(self, checked=False):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行,不能重置")
return
scope, tasks = self._reset_scope_tasks()
if not tasks:
self._set_status("当前没有可重置生成结果的任务")
return
if not self._resettable_tasks(tasks, reset_title=True, reset_cover=True):
self._set_status("当前范围没有可重置的生成结果")
return
mode = self._ask_reset_generated_mode(tasks, scope)
if mode is None:
self._set_status("已取消重置生成结果")
return
reset_title, reset_cover = self._reset_mode_flags(mode)
target_tasks = self._resettable_tasks(
tasks,
reset_title=reset_title,
reset_cover=reset_cover,
)
if not target_tasks:
self._set_status(f"当前范围没有可重置的{self.RESET_MODE_LABELS[mode]}")
return
label = self.RESET_MODE_LABELS[mode]
try:
for task in target_tasks:
db.reset_generated(
task.id,
reset_title=reset_title,
reset_cover=reset_cover,
path=self.db_path,
)
run_id = self._write_generated_reset_run_log(
target_tasks,
scope,
mode,
reset_title=reset_title,
reset_cover=reset_cover,
)
except Exception as exc:
QMessageBox.warning(self, "重置生成结果", str(exc))
self._set_status(f"重置生成结果失败:{exc}")
return
message = (
"action=reset_generated step=db_write result=success "
f"detail=重置{label} scope={scope} total={len(target_tasks)}"
)
self.refresh_tasks()
self._append_generate_log(message)
self._set_status(
f"已重置生成结果:{len(target_tasks)} 条,内容:{label},run_id={run_id}"
)
def _reset_scope_tasks(self):
selection = self.task_table.selectionModel()
selected_rows = []
if selection is not None:
selected_rows = sorted({index.row() for index in selection.selectedRows()})
if selected_rows:
tasks = [self.model.task_at(row) for row in selected_rows]
return "selected", [task for task in tasks if task is not None]
return "filtered", list(self.model.tasks)
def _ask_reset_generated_mode(self, tasks, scope):
scope_text = "选中任务" if scope == "selected" else "当前筛选结果"
title_count = sum(1 for task in tasks if getattr(task, "new_title", None))
cover_count = sum(1 for task in tasks if getattr(task, "new_cover_path", None))
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0))
running_count = sum(1 for task in tasks if getattr(task, "status", None) == "running")
sample_ids = [str(getattr(task, "item_id", "") or "") for task in tasks[:5]]
sample_ids = [item_id for item_id in sample_ids if item_id]
sample_text = "、".join(sample_ids)
if len(tasks) > 5:
sample_text += f" 等 {len(tasks)} 条"
lines = [
"请选择要重置的本地生成内容。",
"",
f"作用范围:{scope_text} {len(tasks)} 条",
f"包含新标题:{title_count} 条",
f"包含新封面:{cover_count} 条",
]
if sample_text:
lines.append(f"商品ID:{sample_text}")
if running_count:
lines.append(f"运行中任务会跳过:{running_count} 条")
lines.extend([
"",
"重置标题:只清空新标题,保留新封面。",
"重置封面:只清空新封面,保留新标题和手动微调标题。",
"重置全部:清空新标题和新封面。",
"",
"不会触碰蝦皮,也不会自动回写 Excel;后续重新生成会消耗 cmhub 点数。",
"默认不删除本地新封面文件。",
])
if committed_count:
lines.extend([
"",
f"注意:其中 {committed_count} 条已经提交过线上。本地重置不会回滚蝦皮,重生成后再更新会再次提交线上。",
])
box = QMessageBox(self)
box.setWindowTitle("重置生成结果")
box.setText("\n".join(lines))
title_button = box.addButton("重置标题", QMessageBox.AcceptRole)
cover_button = box.addButton("重置封面", QMessageBox.AcceptRole)
all_button = box.addButton("重置全部", QMessageBox.DestructiveRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(cancel_button)
box.exec()
clicked = box.clickedButton()
if clicked is title_button:
return "title"
if clicked is cover_button:
return "cover"
if clicked is all_button:
return "all"
return None
def _reset_mode_flags(self, mode):
if mode == "title":
return True, False
if mode == "cover":
return False, True
return True, True
def _resettable_tasks(self, tasks, reset_title=True, reset_cover=True):
resettable = []
for task in tasks:
if getattr(task, "status", None) == "running":
continue
has_title = bool(getattr(task, "new_title", None))
has_cover = bool(getattr(task, "new_cover_path", None))
if reset_title and has_title:
resettable.append(task)
continue
if reset_cover and has_cover:
resettable.append(task)
return resettable
def _write_generated_reset_run_log(
self,
tasks,
scope,
mode,
reset_title=True,
reset_cover=True,
):
task_ids = [getattr(task, "id", None) for task in tasks]
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0))
options = {
"action": "reset_generated",
"scope": scope,
"mode": mode,
"reset_title": bool(reset_title),
"reset_cover": bool(reset_cover),
"task_ids": task_ids,
"committed_count": committed_count,
}
run_id = db.create_run_log(
"reset",
total=len(tasks),
options=options,
path=self.db_path,
)
label = self.RESET_MODE_LABELS[mode]
for task in tasks:
message = (
"action=reset_generated step=db_write result=success "
f"detail=重置{label} task_id={getattr(task, 'id', None)}"
)
db.add_run_log_event(
run_id,
message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
path=self.db_path,
)
db.finish_run_log(
run_id,
status="done",
done=len(tasks),
success_count=len(tasks),
failed_count=0,
summary_json=options,
path=self.db_path,
)
return run_id
def show_task_images(self, index):
if index.isValid() and index.column() == 3:
return
task = self.model.task_at(index.row()) if index.isValid() else self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
dialog = QDialog(self)
dialog.setWindowTitle(f"封面对照:{task.item_id}")
layout = QVBoxLayout(dialog)
images_layout = QHBoxLayout()
images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path))
images_layout.addWidget(self._image_panel("新封面", task.new_cover_path))
layout.addLayout(images_layout)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
dialog.resize(720, 420)
dialog.exec()
def _image_panel(self, title, path):
panel = QWidget()
layout = QVBoxLayout(panel)
layout.addWidget(QLabel(title))
image_label = QLabel()
image_label.setAlignment(Qt.AlignCenter)
image_label.setMinimumSize(260, 260)
image_label.setWordWrap(True)
if path and os.path.exists(str(path)):
pixmap = QPixmap(str(path))
if not pixmap.isNull():
image_label.setPixmap(
pixmap.scaled(
260,
260,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
)
else:
image_label.setText(f"图片无法读取\n{path}")
else:
image_label.setText(f"无图片\n{path or ''}".strip())
layout.addWidget(image_label, 1)
return panel
def _set_generate_running(self, running):
self.generate_button.setEnabled(not running)
self.stop_generate_button.setEnabled(running)
self.reset_generate_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.save_title_button.setEnabled(not running)
self.new_cover_template_button.setEnabled(not running)
self.save_cover_template_button.setEnabled(not running)
self.cover_template_actions_button.setEnabled(not running)
self.save_cover_template_as_action.setEnabled(not running)
self.rename_cover_template_action.setEnabled(not running)
self.delete_cover_template_action.setEnabled(not running)
self.generate_cover_checkbox.setEnabled(not running)
def _forget_generate_thread(self, thread):
if self.generate_thread is thread:
self.generate_thread = None
self.generate_worker = None
def _on_generate_progress(self, payload):
self._update_generate_progress(payload)
self._update_cmhub_balance_label(payload)
self._set_status("生成进度:" + self._generate_progress_text(payload))
def _on_generate_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_generate_failed(self, task_id, error):
self._set_status(f"AI 生成失败:{error}")
def _on_generate_finished(self, payload):
self._set_generate_running(False)
self.refresh_tasks()
self._update_generate_progress(payload)
self._update_cmhub_balance_label(payload)
billing_error = payload.get("billing_error")
if billing_error:
self._show_billing_error(billing_error)
return
if payload.get("error"):
self._set_status(f"AI 生成失败:{payload.get('error')}")
return
self._set_status("AI 生成完成:" + self._generate_progress_text(payload))
def _on_generate_cancelled(self, payload):
self._set_generate_running(False)
self.refresh_tasks()
self._update_generate_progress(payload)
self._update_cmhub_balance_label(payload)
billing_error = payload.get("billing_error")
if billing_error:
self._show_billing_error(billing_error)
return
self._set_status("AI 生成已停止:" + self._generate_progress_text(payload))
def _update_generate_progress(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
title_total = max(0, int(payload.get("title_total", total) or 0))
title_done = max(0, int(payload.get("title_done", 0) or 0))
cover_done = max(0, int(payload.get("cover_done", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
failed = max(0, int(payload.get("failed", 0) or 0))
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
self.title_progress_label.setText(f"标题 {title_done}/{title_total}")
self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}")
self.failed_progress_label.setText(f"失败 {failed}")
self._set_progress_bar(self.title_progress_bar, title_done, title_total)
self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
def _reset_cmhub_balance_label(self):
if self._is_cmhub_backend():
self.cmhub_balance_label.setVisible(True)
self.cmhub_balance_label.setText("cmhub余额:生成后刷新")
else:
self.cmhub_balance_label.setVisible(False)
def _update_cmhub_balance_label(self, payload):
if not self._is_cmhub_backend():
self.cmhub_balance_label.setVisible(False)
return
self.cmhub_balance_label.setVisible(True)
balance = payload.get("points_balance") if isinstance(payload, dict) else None
if balance is None:
if not self.cmhub_balance_label.text():
self.cmhub_balance_label.setText("cmhub余额:未获取")
return
self.cmhub_balance_label.setText(f"cmhub余额:{balance}")
def _is_cmhub_backend(self):
try:
return appconfig.ai_config(self.config).get("backend") == "cmhub"
except Exception:
return False
def _show_billing_error(self, billing_error):
message = billing_error.get("message") or "点数不足,请先充值。"
QMessageBox.warning(self, "AI生成", message)
self._set_status("AI 生成已中止:" + message)
def _set_progress_bar(self, bar, done, total):
maximum = max(1, int(total or 0))
value = min(max(0, int(done or 0)), maximum)
bar.setRange(0, maximum)
bar.setValue(value)
def _cover_total_for_progress(self, payload, total):
cover_total = payload.get("cover_total")
if cover_total is None:
cover_total = total if payload.get("generate_cover", True) else 0
return max(0, int(cover_total or 0))
def _generate_progress_text(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
title_total = max(0, int(payload.get("title_total", total) or 0))
cover_total = self._cover_total_for_progress(payload, total)
return "标题{title}/{title_total} · 图片{cover}/{cover_total} · 失败{failed}".format(
title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0),
cover_total=cover_total,
title_total=title_total,
failed=payload.get("failed", 0),
)
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)
batches = db.list_batches(path=self.db_path)
accounts_rows = accounts.list_accounts(path=self.db_path, config=self.config)
selected_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "all"
item_query = self.item_filter.text().strip()
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path)
self._populate_shop_filter(batch_tasks, accounts_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._set_status(f"AI 生成任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, accounts_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
_set_batch_progress_overview(self.batch_progress_label, batch_tasks)
self._update_empty_state(batch_tasks, filtered_tasks, accounts_rows)
def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows):
if not account_rows:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"第一步:前往『④账号管理』配置并登录账号,再回到②生成标题和封面。",
self.open_accounts_callback is not None,
)
return
if not batch_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有可生成任务。请先在①导入采集完成旧标题和旧封面采集。",
)
return
if not filtered_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"当前筛选没有匹配的生成任务,请调整批次、店铺、商品ID或状态筛选。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
def _populate_batch_filter(self, batches, selected_batch):
previous = selected_batch if selected_batch in {batch.id for batch in batches} else None
self.batch_filter.blockSignals(True)
self.batch_filter.clear()
self.batch_filter.addItem("全部批次", None)
for batch in batches:
self.batch_filter.addItem(self._batch_label(batch), batch.id)
index = self.batch_filter.findData(previous)
self.batch_filter.setCurrentIndex(index if index >= 0 else 0)
self.batch_filter.blockSignals(False)
def _populate_shop_filter(self, tasks, account_rows, selected_shop):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
aliases = []
for task in tasks:
alias = str(task.alias).strip()
if alias and alias not in aliases:
aliases.append(alias)
previous = selected_shop if selected_shop in aliases else None
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases, key=lambda value: self._shop_label(value, account_by_alias)):
self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias)
index = self.shop_filter.findData(previous)
self.shop_filter.setCurrentIndex(index if index >= 0 else 0)
self.shop_filter.blockSignals(False)
def _batch_label(self, batch):
source_files = batch.source_files
first_file = os.path.basename(source_files[0]) if source_files else batch.id
return f"{batch.created_at} · {first_file}"
def _shop_label(self, alias, account_by_alias):
account = account_by_alias.get(alias)
if account is not None:
return f"{account.account_name} ({alias})"
return alias
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "to_generate":
generate_cover = bool(self.generate_cover_checkbox.isChecked())
return ai.is_generatable_task(task, generate_cover=generate_cover) and task.status != "failed"
if selected_status == "generated":
return task.stage == "generated"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "failed":
return task.status == "failed"
if selected_status == "skipped":
return task.status == "skipped"
return True