feat: add generate progress and readable logs

This commit is contained in:
chengma
2026-07-02 14:02:53 +08:00
parent 4c7d4fc624
commit f9c6dd3331
7 changed files with 337 additions and 34 deletions
+97 -7
View File
@@ -62,6 +62,14 @@ def gen_title(
cfg, cfg,
attempts, attempts,
request_kind="json", request_kind="json",
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
on_step,
"title_request",
attempt,
total_attempts,
exc,
model,
),
) )
_notify_step(on_step, "title_parse_response") _notify_step(on_step, "title_parse_response")
text = _extract_text(data).strip() text = _extract_text(data).strip()
@@ -110,11 +118,33 @@ def gen_cover(
attempts, attempts,
request_kind="multipart", request_kind="multipart",
content_type=content_type, content_type=content_type,
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
on_step,
"cover_request",
attempt,
total_attempts,
exc,
model,
),
) )
else: else:
payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution) payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution)
_notify_step(on_step, "cover_request") _notify_step(on_step, "cover_request")
data = _call_with_retry(model, payload, cfg, attempts, request_kind="json") data = _call_with_retry(
model,
payload,
cfg,
attempts,
request_kind="json",
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
on_step,
"cover_request",
attempt,
total_attempts,
exc,
model,
),
)
_notify_step(on_step, "cover_parse_response") _notify_step(on_step, "cover_parse_response")
image_bytes = _extract_image_bytes(data, model, cfg) image_bytes = _extract_image_bytes(data, model, cfg)
@@ -131,6 +161,23 @@ def _notify_step(callback, step):
pass pass
def _notify_retry(callback, step, attempt, attempts, exc, model):
if callback is None:
return
try:
callback(
{
"step": step,
"result": "retry",
"attempt": attempt,
"attempts": attempts,
"detail": _redact(str(exc), model),
}
)
except Exception:
pass
def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None): def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None):
"""Generate titles first, then covers, and persist each successful task.""" """Generate titles first, then covers, and persist each successful task."""
@@ -186,9 +233,25 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
return step_by_task.get(getattr(task, "id", None), fallback) return step_by_task.get(getattr(task, "id", None), fallback)
def step_callback(task, phase): def step_callback(task, phase):
def callback(step): def callback(event):
set_step(task, step) if isinstance(event, dict):
_emit_generation_event(on_event, task, phase, step, "start") step = event.get("step") or "unknown"
result = event.get("result") or "start"
set_step(task, step)
_emit_generation_event(
on_event,
task,
phase,
step,
result,
detail=event.get("detail"),
level=event.get("level") or ("warning" if result == "retry" else "info"),
attempt=event.get("attempt"),
attempts=event.get("attempts"),
)
return
set_step(task, event)
_emit_generation_event(on_event, task, phase, event, "start")
return callback return callback
with ThreadPoolExecutor( with ThreadPoolExecutor(
@@ -300,7 +363,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"new_cover_path": new_cover_path, "new_cover_path": new_cover_path,
}, },
) )
_emit_generation_event(on_event, task, "cover", "db_write", "success") _emit_generation_event(on_event, task, "cover", "db_write", "success", detail=new_cover_path)
except CancelledError: except CancelledError:
summary["cancelled"] = True summary["cancelled"] = True
_emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning") _emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning")
@@ -401,7 +464,17 @@ def _mark_generate_failed(task, exc, db_path, on_task_update):
return error return error
def _emit_generation_event(callback, task, phase, step, result, detail=None, level="info"): def _emit_generation_event(
callback,
task,
phase,
step,
result,
detail=None,
level="info",
attempt=None,
attempts=None,
):
if callback is None: if callback is None:
return return
payload = { payload = {
@@ -413,6 +486,10 @@ def _emit_generation_event(callback, task, phase, step, result, detail=None, lev
} }
if detail is not None: if detail is not None:
payload["detail"] = diagnostics.redact_log_text(detail) payload["detail"] = diagnostics.redact_log_text(detail)
if attempt is not None:
payload["attempt"] = attempt
if attempts is not None:
payload["attempts"] = attempts
try: try:
callback(payload) callback(payload)
except Exception: except Exception:
@@ -475,7 +552,15 @@ def _headers(model, content_type):
} }
def _call_with_retry(model, body, config, attempts, request_kind, content_type=None): def _call_with_retry(
model,
body,
config,
attempts,
request_kind,
content_type=None,
on_retry=None,
):
last_exc = None last_exc = None
for index in range(attempts): for index in range(attempts):
try: try:
@@ -484,6 +569,11 @@ def _call_with_retry(model, body, config, attempts, request_kind, content_type=N
last_exc = exc last_exc = exc
if index + 1 >= attempts: if index + 1 >= attempts:
break break
if on_retry is not None:
try:
on_retry(index + 1, attempts, exc)
except Exception:
pass
time.sleep(min(2.0, 0.4 * (index + 1))) time.sleep(min(2.0, 0.4 * (index + 1)))
raise AIError( raise AIError(
"AI 调用失败(已尝试 %s 次): %s" "AI 调用失败(已尝试 %s 次): %s"
+167 -15
View File
@@ -30,6 +30,7 @@ try:
QMenu, QMenu,
QMessageBox, QMessageBox,
QPlainTextEdit, QPlainTextEdit,
QProgressBar,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSplitter, QSplitter,
@@ -829,6 +830,22 @@ if QT_IMPORT_ERROR is None:
self.reset_generate_button.setObjectName("resetGenerateButton") self.reset_generate_button.setObjectName("resetGenerateButton")
self.stop_generate_button.setEnabled(False) self.stop_generate_button.setEnabled(False)
self.progress_label = QLabel("进度:标题0/0 · 封面0/0 · 失败0") 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")
left_panel = QWidget() left_panel = QWidget()
left_layout = QVBoxLayout(left_panel) left_layout = QVBoxLayout(left_panel)
@@ -916,12 +933,26 @@ if QT_IMPORT_ERROR is None:
self.splitter.setStretchFactor(1, 3) self.splitter.setStretchFactor(1, 3)
self.splitter.setSizes([280, 860]) 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)
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 = QHBoxLayout()
bottom_layout.addWidget(self.progress_label) bottom_layout.addLayout(progress_layout, 1)
bottom_layout.addStretch(1) bottom_layout.addLayout(button_layout)
bottom_layout.addWidget(self.generate_button)
bottom_layout.addWidget(self.stop_generate_button)
bottom_layout.addWidget(self.reset_generate_button)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18) layout.setContentsMargins(18, 18, 18, 18)
@@ -961,6 +992,8 @@ if QT_IMPORT_ERROR is None:
def _append_generate_log(self, message): def _append_generate_log(self, message):
self.run_log_view.appendPlainText(str(message)) self.run_log_view.appendPlainText(str(message))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _load_latest_generate_run_log(self): def _load_latest_generate_run_log(self):
try: try:
@@ -972,9 +1005,11 @@ if QT_IMPORT_ERROR is None:
return return
lines = [ lines = [
f"{event.created_at} [{event.level}] {event.message}" f"{event.created_at} [{event.level}] {event.message}"
for event in events for event in reversed(events)
] ]
self.run_log_view.setPlainText("\n".join(lines)) 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): def save_title_prompt(self, checked=False):
try: try:
@@ -1159,6 +1194,7 @@ if QT_IMPORT_ERROR is None:
def stop_generate(self, checked=False): def stop_generate(self, checked=False):
if self.generate_worker is not None: if self.generate_worker is not None:
self.generate_worker.cancel() self.generate_worker.cancel()
self._append_generate_log("[停止] 已收到停止请求,当前正在运行的任务结束后停止")
self._set_status("正在停止 AI 生成...") self._set_status("正在停止 AI 生成...")
def reset_generated_result(self, checked=False): def reset_generated_result(self, checked=False):
@@ -1312,7 +1348,22 @@ if QT_IMPORT_ERROR is None:
self._set_status("AI 生成已停止:" + self._generate_progress_text(payload)) self._set_status("AI 生成已停止:" + self._generate_progress_text(payload))
def _update_generate_progress(self, payload): def _update_generate_progress(self, payload):
total = max(0, int(payload.get("total", 0) 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))
failed = max(0, int(payload.get("failed", 0) or 0))
self.progress_label.setText("进度:" + self._generate_progress_text(payload)) self.progress_label.setText("进度:" + self._generate_progress_text(payload))
self.title_progress_label.setText(f"标题 {title_done}/{total}")
self.cover_progress_label.setText(f"图片 {cover_done}/{total}")
self.failed_progress_label.setText(f"失败 {failed}")
self._set_progress_bar(self.title_progress_bar, title_done, total)
self._set_progress_bar(self.cover_progress_bar, cover_done, total)
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 _generate_progress_text(self, payload): def _generate_progress_text(self, payload):
return "标题{title}/{total} · 封面{cover}/{total} · 失败{failed}".format( return "标题{title}/{total} · 封面{cover}/{total} · 失败{failed}".format(
@@ -2108,9 +2159,11 @@ if QT_IMPORT_ERROR is None:
return return
lines = [ lines = [
f"{event.created_at} [{event.level}] {event.message}" f"{event.created_at} [{event.level}] {event.message}"
for event in events for event in reversed(events)
] ]
self.run_log_view.setPlainText("\n".join(lines)) self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _show_apply_blocked(self, payload): def _show_apply_blocked(self, payload):
lines = ["更新前检查未通过。"] lines = ["更新前检查未通过。"]
@@ -2453,9 +2506,11 @@ if QT_IMPORT_ERROR is None:
return return
lines = [ lines = [
f"{event.created_at} [{event.level}] {event.message}" f"{event.created_at} [{event.level}] {event.message}"
for event in events for event in reversed(events)
] ]
self.run_log_view.setPlainText("\n".join(lines)) self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _log_collect_run_event(self, run_id, message, level="info"): def _log_collect_run_event(self, run_id, message, level="info"):
safe_message = diagnostics.redact_log_text(message) safe_message = diagnostics.redact_log_text(message)
@@ -3215,6 +3270,9 @@ if QT_IMPORT_ERROR is None:
self.config = config self.config = config
self.diagnostic_log_dir = diagnostic_log_dir self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None self._run_id = None
self._account_by_alias = {}
self._task_positions = {}
self._eligible_total = 0
def execute(self): def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config) account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
@@ -3223,14 +3281,25 @@ if QT_IMPORT_ERROR is None:
for account in account_rows for account in account_rows
if str(account.alias).strip() if str(account.alias).strip()
} }
self._account_by_alias = account_by_alias
eligible = [ eligible = [
task for task in self.tasks task for task in self.tasks
if getattr(task, "stage", None) == "collected" if getattr(task, "stage", None) == "collected"
] ]
self._eligible_total = len(eligible)
self._task_positions = {
getattr(task, "id", None): index
for index, task in enumerate(eligible, start=1)
}
batch_ids = self._batch_ids(eligible) batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids) self._run_id = self._create_run_log(eligible, batch_ids)
ai_cfg = appconfig.ai_config(self.config)
self._log_run_event( self._log_run_event(
f"phase=preflight step=start result=start detail=AI生成开始 total={len(eligible)}" "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
total=len(eligible),
title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=ai_cfg.get("image_concurrency", 1),
)
) )
try: try:
summary = ai.generate_batch( summary = ai.generate_batch(
@@ -3260,7 +3329,7 @@ if QT_IMPORT_ERROR is None:
"cancelled": self.should_cancel(), "cancelled": self.should_cancel(),
} }
self._log_run_event( self._log_run_event(
f"phase=worker step=execute result=failed detail={error}", f"[失败] AI 生成运行失败:{error}",
level="error", level="error",
) )
self._write_diagnostic_log( self._write_diagnostic_log(
@@ -3273,6 +3342,8 @@ if QT_IMPORT_ERROR is None:
summary["run_id"] = self._run_id summary["run_id"] = self._run_id
summary["batch_ids"] = batch_ids summary["batch_ids"] = batch_ids
status = "cancelled" if summary.get("cancelled") else "done" status = "cancelled" if summary.get("cancelled") else "done"
level = "warning" if summary.get("cancelled") or summary.get("error") else "info"
self._log_run_event(self._format_generate_completion(summary), level=level)
self._finish_run_log(status, summary) self._finish_run_log(status, summary)
return summary return summary
@@ -3280,15 +3351,96 @@ if QT_IMPORT_ERROR is None:
self.row_updated.emit(int(task_id), dict(fields or {})) self.row_updated.emit(int(task_id), dict(fields or {}))
def _on_generation_event(self, payload): def _on_generation_event(self, payload):
task = payload.get("task")
message = self._format_generation_event(payload)
if not message:
return
self._log_run_event(message, task=task, level=payload.get("level") or "info")
def _format_generation_event(self, payload):
task = payload.get("task") task = payload.get("task")
phase = payload.get("phase") or "generate" phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown" step = payload.get("step") or "unknown"
result = payload.get("result") or "start" result = payload.get("result") or "start"
detail = payload.get("detail") detail = self._short_detail(payload.get("detail"))
message = f"phase={phase} step={step} result={result}" if phase == "title":
if detail: if result == "start" and step == "title_submit":
message += f" detail={detail}" return f"[标题] {self._task_progress_label(task)} 开始生成"
self._log_run_event(message, task=task, level=payload.get("level") or "info") if result == "success" and step == "title_done":
return f"[标题] {self._task_progress_label(task)} 成功"
if result == "retry":
return self._retry_message("标题", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 标题生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 标题生成已取消"
return None
if phase == "cover":
if result == "start" and step == "cover_submit":
return f"[图片] {self._task_progress_label(task)} 开始生成"
if result == "success" and step == "db_write":
suffix = f",已保存 {detail}" if detail else ""
return f"[图片] {self._task_progress_label(task)} 成功{suffix}"
if result == "retry":
return self._retry_message("图片", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 图片生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 图片生成已取消"
return None
return None
def _retry_message(self, label, task, payload, detail):
attempt = int(payload.get("attempt", 0) or 0)
attempts = int(payload.get("attempts", 0) or 0)
max_retries = max(0, attempts - 1)
retry_text = f"准备重试 {attempt}/{max_retries}" if max_retries else "准备重试"
reason = f":{detail}" if detail else ""
return f"[{label}] {self._task_progress_label(task)} 调用失败,{retry_text}{reason}"
def _task_progress_label(self, task):
index = self._task_positions.get(getattr(task, "id", None), 0)
total = self._eligible_total or 0
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
shop_text = f"({shop})" if shop else ""
return f"{index}/{total} 商品 {item_id}{shop_text}"
def _task_plain_label(self, task):
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
return f"商品 {item_id}({shop})" if shop else f"商品 {item_id}"
def _task_shop_label(self, task):
alias = str(getattr(task, "alias", "") or "").strip()
account = self._account_by_alias.get(alias)
if account is not None:
return getattr(account, "account_name", None) or getattr(account, "alias", None) or alias
return getattr(task, "account_name", None) or alias
def _short_detail(self, detail):
if detail is None:
return ""
text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip()
if len(text) > 180:
return text[:177] + "..."
return text
def _format_generate_completion(self, summary):
progress = self._summary_text(summary)
if summary.get("cancelled"):
return f"[停止] AI 生成已停止:{progress}"
if summary.get("error"):
return f"[失败] AI 生成失败:{summary.get('error')},{progress}"
return f"[完成] AI 生成完成:{progress}"
def _summary_text(self, summary):
return "标题{title}/{total},图片{cover}/{total},失败{failed}".format(
title=summary.get("title_done", 0),
cover=summary.get("cover_done", 0),
total=summary.get("total", 0),
failed=summary.get("failed", 0),
)
def _on_generation_error(self, payload): def _on_generation_error(self, payload):
task = payload.get("task") task = payload.get("task")
+1
View File
@@ -102,6 +102,7 @@
| T-516 | ①筛选对齐②③ | T-202, T-303b | 依据 `docs/ux-review.md` P2。① 导入采集补齐与②③一致的店铺、商品ID、状态筛选,保持三个列表页筛选心智一致。筛选栏顺序为批次/店铺/商品ID/状态;当前筛选结果用于①表格显示与「采集旧标题/旧封面」作用范围;导入汇总栏、批次进度总览、未匹配数量仍按当前批次全量任务统计,未匹配按钮继续筛出当前筛选结果中的未匹配任务;不改导入、采集、回写、CDP 或 Shopee 更新逻辑 | DONE | | T-516 | ①筛选对齐②③ | T-202, T-303b | 依据 `docs/ux-review.md` P2。① 导入采集补齐与②③一致的店铺、商品ID、状态筛选,保持三个列表页筛选心智一致。筛选栏顺序为批次/店铺/商品ID/状态;当前筛选结果用于①表格显示与「采集旧标题/旧封面」作用范围;导入汇总栏、批次进度总览、未匹配数量仍按当前批次全量任务统计,未匹配按钮继续筛出当前筛选结果中的未匹配任务;不改导入、采集、回写、CDP 或 Shopee 更新逻辑 | DONE |
| T-517 | ⑤设置分区 + 清理兼容字段 | T-506, T-507 | 依据 `docs/ux-review.md` P2。⑤ 视觉分区:将高频「Shopee 更新安全 / 执行模式」前置成独立分区,低频「基础设施(Chrome路径/端口/DB路径)」独立后置;彻底隐藏 `test_item_id`、`dry_run` 用户入口,不再占用可见表单位置,但保留 `config.json` 内部兼容字段与③「检查本轮更新」语义。只改⑤设置页 UI 分区、保存/加载展示和 GUI 测试,不改配置 schema,不改 Shopee/CDP 更新流程 | DONE | | T-517 | ⑤设置分区 + 清理兼容字段 | T-506, T-507 | 依据 `docs/ux-review.md` P2。⑤ 视觉分区:将高频「Shopee 更新安全 / 执行模式」前置成独立分区,低频「基础设施(Chrome路径/端口/DB路径)」独立后置;彻底隐藏 `test_item_id`、`dry_run` 用户入口,不再占用可见表单位置,但保留 `config.json` 内部兼容字段与③「检查本轮更新」语义。只改⑤设置页 UI 分区、保存/加载展示和 GUI 测试,不改配置 schema,不改 Shopee/CDP 更新流程 | DONE |
| T-518 | ②左栏提示词区组件密度优化 | T-302p, T-303, T-517 | 依据 `docs/ux-review.md` P2。② AI生成左栏保留高频「模板选择、编辑内容、保存当前模板/开始生成」可见入口;将封面模板低频操作「另存为、重命名、删除」收敛进一个「模板操作」菜单/小按钮,避免左栏按钮堆叠和误点。第一版不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel、Shopee/CDP 流程;删除仍保留现有二次确认,保存/重命名/删除行为和错误处理不变。同步 GUI 测试覆盖菜单入口、动作触发和旧低频按钮不再平铺 | DONE | | T-518 | ②左栏提示词区组件密度优化 | T-302p, T-303, T-517 | 依据 `docs/ux-review.md` P2。② AI生成左栏保留高频「模板选择、编辑内容、保存当前模板/开始生成」可见入口;将封面模板低频操作「另存为、重命名、删除」收敛进一个「模板操作」菜单/小按钮,避免左栏按钮堆叠和误点。第一版不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel、Shopee/CDP 流程;删除仍保留现有二次确认,保存/重命名/删除行为和错误处理不变。同步 GUI 测试覆盖菜单入口、动作触发和旧低频按钮不再平铺 | DONE |
| T-519 | ②AI生成长任务进度条 + 用户可读滚动日志 | T-303, T-505, T-518 | 问题:批量生成几十/上百个标题或图片超时重试时,仅底部文字 `进度:标题x/y · 封面x/y · 失败z` 和技术型结构化日志不足以让用户判断剩余量、是否卡住、慢在哪一步。方案:②底部操作区增加标题/图片两条独立 `QProgressBar`,分两行显示 `标题 x/y`、`图片 x/y`,标题阶段结束后保持 100%,图片进度按本轮总任务口径显示并在旁边保留失败数;现有「AI生成运行日志」改为用户可读自动滚动日志,显示本轮开始、标题/图片开始与成功、商品ID/店铺、图片超时/重试、失败原因、停止请求和完成汇总。日志不得显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt;保留 DB `run_logs/run_log_events` 脱敏记录。只改②生成 UI、`GenerateWorker`/AI 生成事件文案和 GUI/AI 单测,不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
## 里程碑 ## 里程碑
File diff suppressed because one or more lines are too long
+14
View File
@@ -992,3 +992,17 @@
- 范围控制:不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel 或 Shopee/CDP 流程。 - 范围控制:不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel 或 Shopee/CDP 流程。
- 测试:`tests/test_gui.py` 覆盖「模板操作」菜单入口、菜单 action 触发原有行为,以及旧低频按钮不再平铺。 - 测试:`tests/test_gui.py` 覆盖「模板操作」菜单入口、菜单 action 触发原有行为,以及旧低频按钮不再平铺。
- 验证:`python -m py_compile app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(68 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(156 tests)。Qt 仍有本机字体目录提示,不影响测试结果。 - 验证:`python -m py_compile app\gui.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(68 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(156 tests)。Qt 仍有本机字体目录提示,不影响测试结果。
## 【2026-07-02】T-519 文档启动 · ②AI生成长任务进度条与用户日志
- 背景:用户反馈批量生成几十/上百个标题或图片超时重试时耗时较长,需要在②AI生成里更清楚地看到剩余量、是否卡住、慢在哪一步。
- 文档:`docs/06-tasks.md` 新增 T-519 并标记 DOING,明确②底部增加标题/图片两条独立进度条,现有「AI生成运行日志」增强为用户可读自动滚动日志;日志展示本轮开始、标题/图片开始与成功、商品ID/店铺、图片超时/重试、失败原因、停止请求和完成汇总,同时禁止显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt。
- 范围:只改②生成 UI、`GenerateWorker`/AI 生成事件文案和测试;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。
- 验证:文档-only 更新,未运行单元测试。
- 下一步:按 T-519 文档更新 `app/gui.py`、`app/ai.py` 和相关测试。
## 【2026-07-02】T-519 完成 · ②AI生成长任务进度条与用户日志
- 代码:`app/gui.py` 在②AI生成底部增加标题/图片两条独立 `QProgressBar`,分别显示 `标题 x/y`、`图片 x/y` 和失败数;保留原进度文本用于状态汇总;现有「AI生成运行日志」追加后自动滚到底部,加载最近一次日志时按时间顺序显示。
- 日志:`GenerateWorker` 将生成事件转为用户可读短句,显示本轮开始、标题/图片开始与成功、商品ID/店铺、调用失败重试、失败、停止请求和完成汇总;日志继续通过脱敏工具处理并写入 `run_logs/run_log_events`。
- AI事件:`app/ai.py` 的 `_call_with_retry()` 支持 `on_retry` 回调,标题/图片请求失败但仍会重试时上报 `retry` 事件;不改 AI HTTP 协议、DB schema、Excel 或 Shopee/CDP 流程。
- 测试:`tests/test_gui.py` 覆盖双进度条初始状态、用户可读日志、重试日志脱敏和旧日志加载;`tests/test_ai.py` 覆盖标题生成重试事件上报。
- 验证:`python -m py_compile app\ai.py app\gui.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(68 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(156 tests)。Qt 仍有本机字体目录提示,不影响测试结果。
+11
View File
@@ -99,6 +99,7 @@ class AITests(TempDirMixin, unittest.TestCase):
models_path = os.path.join(temp_dir, "ai_models.json") models_path = os.path.join(temp_dir, "ai_models.json")
self._write_models(models_path) self._write_models(models_path)
calls = [] calls = []
steps = []
def fake_urlopen(request, timeout=None): def fake_urlopen(request, timeout=None):
calls.append((request, timeout)) calls.append((request, timeout))
@@ -112,10 +113,18 @@ class AITests(TempDirMixin, unittest.TestCase):
"旧标题", "旧标题",
config=self._config(), config=self._config(),
models_path=models_path, models_path=models_path,
on_step=steps.append,
) )
self.assertEqual("新标题", title) self.assertEqual("新标题", title)
self.assertEqual(2, len(calls)) self.assertEqual(2, len(calls))
retry_events = [step for step in steps if isinstance(step, dict)]
self.assertEqual(1, len(retry_events))
self.assertEqual("title_request", retry_events[0]["step"])
self.assertEqual("retry", retry_events[0]["result"])
self.assertEqual(1, retry_events[0]["attempt"])
self.assertEqual(2, retry_events[0]["attempts"])
self.assertNotIn("sk-text-secret", retry_events[0]["detail"])
body = json.loads(calls[-1][0].data.decode("utf-8")) body = json.loads(calls[-1][0].data.decode("utf-8"))
self.assertEqual("text-model", body["model"]) self.assertEqual("text-model", body["model"])
self.assertEqual(0, body["temperature"]) self.assertEqual(0, body["temperature"])
@@ -143,6 +152,7 @@ class AITests(TempDirMixin, unittest.TestCase):
}, },
) )
calls = [] calls = []
steps = []
def fake_urlopen(request, timeout=None): def fake_urlopen(request, timeout=None):
calls.append(request) calls.append(request)
@@ -154,6 +164,7 @@ class AITests(TempDirMixin, unittest.TestCase):
"旧标题", "旧标题",
config=self._config(), config=self._config(),
models_path=models_path, models_path=models_path,
on_step=steps.append,
) )
self.assertEqual("新标题", title) self.assertEqual("新标题", title)
+38 -4
View File
@@ -17,7 +17,7 @@ if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtGui import QTextCursor from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableView from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
from app.gui import ( from app.gui import (
AccountDialog, AccountDialog,
@@ -696,6 +696,15 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("重置生成结果", tab.reset_generate_button.text()) self.assertEqual("重置生成结果", tab.reset_generate_button.text())
self.assertFalse(tab.stop_generate_button.isEnabled()) self.assertFalse(tab.stop_generate_button.isEnabled())
self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text()) self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text())
self.assertIsInstance(tab.title_progress_bar, QProgressBar)
self.assertIsInstance(tab.cover_progress_bar, QProgressBar)
self.assertEqual("标题 0/0", tab.title_progress_label.text())
self.assertEqual("图片 0/0", tab.cover_progress_label.text())
self.assertEqual("失败 0", tab.failed_progress_label.text())
self.assertEqual(0, tab.title_progress_bar.value())
self.assertEqual(0, tab.cover_progress_bar.value())
self.assertEqual(1, tab.title_progress_bar.maximum())
self.assertEqual(1, tab.cover_progress_bar.maximum())
self.assertEqual("默认", tab.cover_template_combo.currentText()) self.assertEqual("默认", tab.cover_template_combo.currentText())
self.assertEqual("模板操作", tab.cover_template_actions_button.text()) self.assertEqual("模板操作", tab.cover_template_actions_button.text())
self.assertIs(tab.cover_template_actions_menu, tab.cover_template_actions_button.menu()) self.assertIs(tab.cover_template_actions_menu, tab.cover_template_actions_button.menu())
@@ -905,6 +914,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
progress = [] progress = []
rows = [] rows = []
logs = []
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
self.assertEqual(tasks, tasks_arg) self.assertEqual(tasks, tasks_arg)
@@ -919,10 +929,22 @@ class GuiTests(TempDirMixin, unittest.TestCase):
{ {
"task": tasks[0], "task": tasks[0],
"phase": "cover", "phase": "cover",
"step": "cover_request", "step": "cover_submit",
"result": "start", "result": "start",
} }
) )
ai_cfg["on_event"](
{
"task": tasks[0],
"phase": "cover",
"step": "cover_request",
"result": "retry",
"attempt": 1,
"attempts": 3,
"detail": "timeout token=SECRET-TOKEN",
"level": "warning",
}
)
ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"}) ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"})
return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0} return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0}
@@ -934,6 +956,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
) )
worker.progress.connect(progress.append) worker.progress.connect(progress.append)
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
worker.log.connect(logs.append)
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
summary = worker.execute() summary = worker.execute()
@@ -945,8 +968,17 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows) self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows)
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
self.assertEqual(summary["run_id"], run_log.id) self.assertEqual(summary["run_id"], run_log.id)
joined_logs = "\n".join(logs)
self.assertIn("[开始] 本轮生成 1 条", joined_logs)
self.assertIn("[图片] 1/1 商品 51100639510", joined_logs)
self.assertIn("准备重试 1/2", joined_logs)
self.assertIn("token=***", joined_logs)
self.assertNotIn("SECRET-TOKEN", joined_logs)
self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs)
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
self.assertTrue(any("phase=cover step=cover_request result=start" in event.message for event in events)) event_messages = "\n".join(event.message for event in events)
self.assertIn("[图片] 1/1 商品 51100639510", event_messages)
self.assertIn("准备重试 1/2", event_messages)
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -1010,7 +1042,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
messages = "\n".join(event.message for event in events) messages = "\n".join(event.message for event in events)
self.assertIn("phase=cover step=cover_request result=failed", messages) self.assertIn("[失败] 商品 51100639510", messages)
self.assertIn("图片生成失败", messages)
self.assertIn("token=***", messages) self.assertIn("token=***", messages)
self.assertNotIn("SECRET-TOKEN", messages) self.assertNotIn("SECRET-TOKEN", messages)
@@ -1596,6 +1629,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
close_flags = [] close_flags = []
progress = [] progress = []
rows = [] rows = []
logs = []
def fake_apply(account, task, close_success_tab=False, on_step=None): def fake_apply(account, task, close_success_tab=False, on_step=None):
applied_aliases.append(account.alias) applied_aliases.append(account.alias)