From b1c050f6339c37eb67f970727c1abcb5c0d75bcf Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 13 Jul 2026 09:54:08 +0800 Subject: [PATCH] feat(ai-studio): separate image pool from task states --- app/db.py | 33 +++++ app/gui/tabs/image_studio.py | 184 ++++++++++++++++++++------ app/image_studio.py | 37 +++++- app/image_studio_generation.py | 51 ++++++- docs/routes.md | 4 +- docs/tasks/T-613.md | 11 +- tests/test_gui.py | 130 +++++++++++++++++- tests/test_image_studio.py | 95 +++++++++++++ tests/test_image_studio_generation.py | 14 ++ 9 files changed, 500 insertions(+), 59 deletions(-) diff --git a/app/db.py b/app/db.py index 679f7cc..5ae4bed 100644 --- a/app/db.py +++ b/app/db.py @@ -343,6 +343,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs ( status TEXT NOT NULL DEFAULT 'pending', prompt TEXT, error TEXT, + recovery_action TEXT NOT NULL DEFAULT 'regenerate', attempts INTEGER NOT NULL DEFAULT 0, points_cost INTEGER, points_balance INTEGER, @@ -477,6 +478,7 @@ def init_db(path=None, conn=None) -> None: _ensure_batch_delete_columns(database) _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) + _ensure_image_studio_job_recovery_columns(database) def _ensure_batch_delete_columns(database): @@ -502,6 +504,37 @@ def _ensure_task_cover_reset_columns(database): if "cover_reset_at" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") + +def _ensure_image_studio_job_recovery_columns(database): + columns = { + row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() + } + if "recovery_action" not in columns: + database.execute( + "ALTER TABLE image_studio_jobs ADD COLUMN recovery_action TEXT NOT NULL DEFAULT 'regenerate'" + ) + database.execute( + """ + UPDATE image_studio_jobs + SET recovery_action = CASE + WHEN status = 'succeeded' THEN 'none' + WHEN task_id IS NOT NULL AND status IN ('submitted', 'running') THEN 'resume' + ELSE 'regenerate' + END + WHERE recovery_action IS NULL + OR recovery_action NOT IN ('none', 'resume', 'regenerate') + OR ( + recovery_action = 'regenerate' + AND task_id IS NOT NULL + AND status IN ('submitted', 'running') + ) + OR ( + recovery_action = 'regenerate' + AND status = 'succeeded' + ) + """ + ) + def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 95f946c..dc7c0ef 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -7,7 +7,7 @@ import os from PySide6.QtCore import QObject, QMimeData, QSize, Signal from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy -from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, image_studio_images, prompts +from ... import accounts, appconfig, cmhub_models, db, diagnostics, image_studio, image_studio_export, image_studio_images, prompts from .. import file_manager from ..widgets import * from ..workers import ( @@ -266,12 +266,12 @@ class ImageStudioTab(QWidget): PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"] JOB_STATUS_LABELS = { - "pending": "排队中", + "pending": "等待提交", "submitted": "已提交", "running": "生成中", "succeeded": "成功", - "failed": "失败", - "expired": "已过期", + "failed": "生成失败", + "expired": "任务过期", "cancelled": "已停止", } @@ -454,6 +454,9 @@ class ImageStudioTab(QWidget): pool_title = QLabel("照片池") pool_title.setObjectName("imageStudioSectionTitle") pool_header.addWidget(pool_title) + self.pool_summary_label = QLabel("可用图片 0 张") + self.pool_summary_label.setObjectName("imageStudioMutedLabel") + pool_header.addWidget(self.pool_summary_label) pool_header.addStretch(1) self.source_label = QLabel("源图:未选择") self.source_label.setObjectName("imageStudioSourceLabel") @@ -466,6 +469,30 @@ class ImageStudioTab(QWidget): self.pool_grid.setGridSize(QSize(108, 124)) self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu) layout.addWidget(self.pool_grid, 2) + + self.job_section = QWidget() + self.job_section.setObjectName("imageStudioJobSection") + job_layout = QVBoxLayout(self.job_section) + job_layout.setContentsMargins(0, 0, 0, 0) + job_layout.setSpacing(4) + job_header = QHBoxLayout() + job_title = QLabel("生成任务") + job_title.setObjectName("imageStudioSectionTitle") + job_header.addWidget(job_title) + self.job_summary_label = QLabel("未完成/异常任务 0 个") + self.job_summary_label.setObjectName("imageStudioMutedLabel") + job_header.addWidget(self.job_summary_label) + job_header.addStretch(1) + job_layout.addLayout(job_header) + self.job_grid = ImageStudioThumbnailGrid(parent=self.job_section) + self.job_grid.setObjectName("imageStudioJobGrid") + self.job_grid.setIconSize(QSize(42, 42)) + self.job_grid.setGridSize(QSize(162, 94)) + self.job_grid.setMinimumHeight(100) + self.job_grid.setMaximumHeight(112) + job_layout.addWidget(self.job_grid) + self.job_section.setVisible(False) + layout.addWidget(self.job_section, 0) return panel def _build_generation_panel(self): @@ -1110,6 +1137,7 @@ class ImageStudioTab(QWidget): self._refresh_project_summary() self._fill_original_grid() self._fill_pool_grid() + self._fill_job_grid() self._refresh_selection_labels() self._refresh_source_label() @@ -1148,40 +1176,71 @@ class ImageStudioTab(QWidget): self._refresh_original_download_label() def _fill_pool_grid(self): - rows = [] - for asset in self.assets: - if asset.kind not in {"original", "generated_main", "generated_detail"} or not _asset_is_usable(asset): - continue - rows.append(("asset", asset)) - for job in self.jobs: - if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}: - rows.append(("job", job)) + assets = [ + asset + for asset in self.assets + if asset.kind in {"original", "generated_main", "generated_detail"} and _asset_is_usable(asset) + ] self.pool_grid.clear() - for row_type, obj in rows: - if row_type == "asset": - draggable = _asset_is_usable(obj) - data = {"type": "asset", "asset_id": int(obj.id), "draggable": draggable} - text = f"{_asset_badge(obj.kind)} #{obj.id}\n{obj.aspect_ratio or '比例未知'}" - tooltip = "单击设为源图,双击查看大图,可拖入终选槽。" - else: - data = {"type": "job", "job_id": int(obj.id)} - text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}" - tooltip = "生图任务已保存,可继续查询。" + for asset in assets: + draggable = _asset_is_usable(asset) + data = {"type": "asset", "asset_id": int(asset.id), "draggable": draggable} + text = f"{_asset_badge(asset.kind)} #{asset.id}\n{asset.aspect_ratio or '比例未知'}" + tooltip = "单击设为源图,双击查看大图,可拖入终选槽。" item = QListWidgetItem(text) item.setData(Qt.UserRole, data) item.setSizeHint(QSize(108, 124)) item.setToolTip(tooltip) - if row_type == "asset": - item.setIcon(_asset_icon(obj, _asset_badge(obj.kind), size=QSize(86, 86))) - if obj.id == self.selected_source_asset_id: - item.setBackground(QColor("#eaf2ff")) - else: - item.setIcon(_job_icon(obj.status)) - if obj.status in {"failed", "expired"}: - item.setForeground(_qcolor(COLOR_DANGER)) - elif obj.status in {"pending", "submitted", "running"}: - item.setForeground(_qcolor(COLOR_WARNING)) + item.setIcon(_asset_icon(asset, _asset_badge(asset.kind), size=QSize(86, 86))) + if asset.id == self.selected_source_asset_id: + item.setBackground(QColor("#eaf2ff")) self.pool_grid.addItem(item) + self.pool_summary_label.setText(f"可用图片 {len(assets)} 张") + + def _fill_job_grid(self): + usable_asset_ids = { + int(asset.id) + for asset in self.assets + if _asset_is_usable(asset) + } + jobs = [ + job + for job in self.jobs + if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"} + and int(job.output_asset_id or 0) not in usable_asset_ids + ] + self.job_grid.clear() + self.job_summary_label.setText(f"未完成/异常任务 {len(jobs)} 个") + self.job_section.setVisible(bool(jobs)) + for job in jobs: + status_text = self.JOB_STATUS_LABELS.get(job.status, "任务状态未知") + reason = _job_error_summary(job) + recovery = _job_recovery_summary(job) + billing = _job_billing_text(job) + headline = status_text if not reason else f"{status_text}:{reason}" + lines = [headline, recovery] + if billing: + lines.append(billing) + item = QListWidgetItem("\n".join(lines)) + item.setData(Qt.UserRole, {"type": "job", "job_id": int(job.id), "draggable": False}) + item.setSizeHint(QSize(162, 94)) + tooltip_lines = [f"状态:{status_text}", recovery] + if reason: + tooltip_lines.append(f"原因:{reason}") + if billing: + tooltip_lines.append(billing) + item.setToolTip("\n".join(tooltip_lines)) + item.setIcon(_job_icon(job.status, size=QSize(42, 42))) + if job.status in {"failed", "expired"}: + item.setForeground(_qcolor(COLOR_DANGER)) + item.setBackground(QColor("#ffebe9")) + elif job.status in {"pending", "submitted", "running"}: + item.setForeground(_qcolor(COLOR_WARNING)) + item.setBackground(QColor("#fff8c5")) + else: + item.setForeground(_qcolor(COLOR_MUTED)) + item.setBackground(QColor("#f6f8fa")) + self.job_grid.addItem(item) def _selection_asset_ids(self, selection_type): return [ @@ -1520,6 +1579,7 @@ class ImageStudioTab(QWidget): self.prompt_edit.blockSignals(False) self._fill_original_grid() self._fill_pool_grid() + self._fill_job_grid() self._refresh_selection_labels() self._refresh_source_label() self._refresh_project_summary() @@ -2019,6 +2079,7 @@ class ImageStudioTab(QWidget): self.project_table.setEnabled(not running) self.original_grid.setEnabled(not running) self.pool_grid.setEnabled(not running or generation_running) + self.job_grid.setEnabled(not running or generation_running) self.main_selection_list.setEnabled(not running or generation_running) self.detail_selection_list.setEnabled(not running or generation_running) self.template_combo.setEnabled(not running or generation_running) @@ -2229,15 +2290,15 @@ def _asset_icon(asset, fallback_label, size=None, cached_pixmap=None): ) -def _job_icon(status): +def _job_icon(status, size=None): label = { - "pending": "排", - "submitted": "提", - "running": "生", - "failed": "败", - "expired": "过", + "pending": "…", + "submitted": "…", + "running": "…", + "failed": "!", + "expired": "!", "cancelled": "停", - }.get(str(status or ""), "任") + }.get(str(status or ""), "?") color = { "failed": "#ffebe9", "expired": "#ffebe9", @@ -2246,7 +2307,7 @@ def _job_icon(status): "submitted": "#fff8c5", "pending": "#f6f8fa", }.get(str(status or ""), "#f6f8fa") - return QIcon(_placeholder_pixmap(label, QSize(86, 86), color)) + return QIcon(_placeholder_pixmap(label, size or QSize(42, 42), color)) def _asset_pixmap(asset, size, fallback_label=None, cached_pixmap=None): @@ -2337,6 +2398,20 @@ def _selection_tooltip(selection_type, asset): def _job_status_text(job, labels): parts = [labels.get(job.status, job.status)] + recovery = _job_recovery_summary(job) + if recovery: + parts.append(recovery) + error = _job_error_summary(job) + if error: + parts.append(error) + billing = _job_billing_text(job) + if billing: + parts.append(billing) + return ",".join(parts) + + +def _job_billing_text(job): + parts = [] if job.points_cost is not None: parts.append(f"扣点{job.points_cost}") if job.points_balance is not None: @@ -2344,3 +2419,32 @@ def _job_status_text(job, labels): if job.call_id: parts.append(f"call_id={job.call_id}") return ",".join(parts) + + +def _job_recovery_summary(job): + if getattr(job, "recovery_action", "") == image_studio.JOB_RECOVERY_RESUME: + return "可继续查询,不会重复扣点" + if job.status == "pending": + return "等待本轮提交" + if job.status == "submitted": + return "等待任务结果" + if job.status == "running": + return "正在生成,可稍后继续查询" + return "需要重新生成,可能再次扣点" + + +def _job_error_summary(job): + raw = " ".join(str(getattr(job, "error", "") or "").split()) + if not raw: + return "" + text = diagnostics.redact_log_text(raw) + lowered = text.lower() + if ( + "http://" in lowered + or "https://" in lowered + or "/api/" in lowered + or "traceback" in lowered + or any(char.isascii() and char.isalpha() for char in text) + ): + return "任务未完成,请按恢复方式处理" + return text[:24] + ("…" if len(text) > 24 else "") diff --git a/app/image_studio.py b/app/image_studio.py index d510a5d..bfcdc1a 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -20,6 +20,14 @@ ASSET_STATUS_MISSING = "missing" ASSET_STATUSES = {ASSET_STATUS_AVAILABLE, ASSET_STATUS_MISSING} JOB_STATUSES = {"pending", "submitted", "running", "succeeded", "failed", "expired", "cancelled"} JOB_RESUMABLE_STATUSES = {"submitted", "running"} +JOB_RECOVERY_NONE = "none" +JOB_RECOVERY_RESUME = "resume" +JOB_RECOVERY_REGENERATE = "regenerate" +JOB_RECOVERY_ACTIONS = { + JOB_RECOVERY_NONE, + JOB_RECOVERY_RESUME, + JOB_RECOVERY_REGENERATE, +} SELECTION_TYPES = {"main", "detail"} @@ -70,6 +78,7 @@ class ImageStudioJob: status: str prompt: Optional[str] error: Optional[str] + recovery_action: str attempts: int points_cost: Optional[int] points_balance: Optional[int] @@ -677,6 +686,7 @@ def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points UPDATE image_studio_jobs SET task_id = ?, status = 'submitted', + recovery_action = ?, call_id = ?, points_cost = ?, points_balance = ?, @@ -684,7 +694,16 @@ def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points updated_at = ? WHERE id = ? """, - (str(task_id), call_id, points_cost, points_balance, now, now, int(job_id)), + ( + str(task_id), + JOB_RECOVERY_RESUME, + call_id, + points_cost, + points_balance, + now, + now, + int(job_id), + ), ) return get_job(job_id, conn=database) @@ -696,12 +715,17 @@ def update_job_status( error=None, output_asset_id=None, points_balance=None, + recovery_action=None, increment_attempts=False, path=None, conn=None, ): if str(status) not in JOB_STATUSES: raise db.DbError("AI工场任务状态无效") + if recovery_action is not None and str(recovery_action) not in JOB_RECOVERY_ACTIONS: + raise db.DbError("AI工场任务恢复方式无效") + if recovery_action is None and str(status) == "succeeded": + recovery_action = JOB_RECOVERY_NONE now = _now() terminal = str(status) in {"succeeded", "failed", "expired", "cancelled"} with _connection(conn, path) as database: @@ -716,6 +740,7 @@ def update_job_status( UPDATE image_studio_jobs SET status = ?, error = ?, + recovery_action = COALESCE(?, recovery_action), output_asset_id = COALESCE(?, output_asset_id), points_balance = COALESCE(?, points_balance), attempts = attempts + ?, @@ -726,6 +751,7 @@ def update_job_status( ( str(status), error, + recovery_action, output_asset_id, points_balance, 1 if increment_attempts else 0, @@ -742,12 +768,13 @@ def list_resumable_jobs(path=None, conn=None, project_id=None, include_failed_do if include_failed_downloads: clauses = [ "task_id IS NOT NULL", - "(status IN (?, ?) OR (status = ? AND output_asset_id IS NULL))", + "recovery_action = ?", + "status IN (?, ?, ?, ?)", ] - params = ["submitted", "running", "failed"] + params = [JOB_RECOVERY_RESUME, "submitted", "running", "failed", "cancelled"] else: - clauses = ["status IN (?, ?)", "task_id IS NOT NULL"] - params = ["submitted", "running"] + clauses = ["status IN (?, ?)", "task_id IS NOT NULL", "recovery_action = ?"] + params = ["submitted", "running", JOB_RECOVERY_RESUME] if project_id is not None: clauses.append("project_id = ?") params.append(int(project_id)) diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 0013136..535d8f9 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -194,7 +194,13 @@ def run_jobs( if should_stop(): for future, job in list(futures.items()): if future.cancel(): - image_studio.update_job_status(job.id, "cancelled", error="用户停止", path=path) + image_studio.update_job_status( + job.id, + "cancelled", + error="用户停止", + recovery_action=_recovery_action_for_job(job), + path=path, + ) futures.pop(future, None) record({"job": job, "status": "cancelled", "error": "用户停止"}) return summary @@ -205,12 +211,24 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho if job is None: raise ImageStudioGenerationError("AI工场生图任务不存在") if should_stop(): - updated = image_studio.update_job_status(job.id, "cancelled", error="用户停止", path=db_path) + updated = image_studio.update_job_status( + job.id, + "cancelled", + error="用户停止", + recovery_action=_recovery_action_for_job(job), + path=db_path, + ) return {"job": updated, "status": "cancelled", "error": "用户停止"} project = image_studio.get_project(job.project_id, path=db_path) source_asset = image_studio.get_asset(job.source_asset_id, path=db_path) if project is None or source_asset is None: - updated = image_studio.update_job_status(job.id, "failed", error="项目或源图不存在", path=db_path) + updated = image_studio.update_job_status( + job.id, + "failed", + error="项目或源图不存在", + recovery_action=image_studio.JOB_RECOVERY_REGENERATE, + path=db_path, + ) return {"job": updated, "status": "failed", "error": "项目或源图不存在"} try: image_studio.update_job_status(job.id, "running", path=db_path) @@ -248,7 +266,14 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho return {"job": updated, "asset": asset, "status": "succeeded"} except Exception as exc: status = "cancelled" if "停止" in str(exc) else "failed" - updated = image_studio.update_job_status(job.id, status, error=str(exc), path=db_path) + current_job = image_studio.get_job(job.id, path=db_path) + updated = image_studio.update_job_status( + job.id, + status, + error=str(exc), + recovery_action=_recovery_action_for_job(current_job), + path=db_path, + ) _notify(on_event, {"job_id": job.id, "step": "job_done", "result": status, "detail": str(exc)}) return {"job": updated, "status": status, "error": str(exc)} @@ -363,7 +388,13 @@ def _poll_job(job_id, task_id, runtime, request_result, db_path, should_stop, on if status in {"failed", "expired"}: error = data.get("error") if isinstance(data.get("error"), dict) else {} message = str(error.get("message") or error.get("code") or status) - image_studio.update_job_status(job_id, status, error=message, path=db_path) + image_studio.update_job_status( + job_id, + status, + error=message, + recovery_action=image_studio.JOB_RECOVERY_REGENERATE, + path=db_path, + ) raise ImageStudioGenerationError(message) raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误") @@ -394,3 +425,13 @@ def _raise_if_stopped(should_stop): stopped = False if stopped: raise ImageStudioGenerationError("用户已停止,已提交任务可稍后继续查询") + + +def _recovery_action_for_job(job): + if ( + job is not None + and getattr(job, "task_id", None) + and getattr(job, "recovery_action", None) == image_studio.JOB_RECOVERY_RESUME + ): + return image_studio.JOB_RECOVERY_RESUME + return image_studio.JOB_RECOVERY_REGENERATE diff --git a/docs/routes.md b/docs/routes.md index 2b2cf46..c9f014f 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -193,10 +193,10 @@ - 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。 - 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。 - 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。 -- 照片池展示原图、生成主图、生成详情图和在途/失败任务状态;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。 +- 照片池只展示本地文件存在且可解码使用的原图、生成主图和生成详情图;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。照片池附近的「生成任务」区单独展示尚未产出可用图片的等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并显示脱敏后的中文原因摘要、恢复方式和已有计费信息;任务卡不能设为源图或拖入终选。 - 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 - 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 -- 「继续查询任务」会恢复当前项目已提交、生成中或下载失败但已有 `task_id` 的 cmhub 生图任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。照片池中的任务行显示排队/生成/失败/过期/停止状态,并附带扣点、余额和 `call_id`,便于运营和技术排障。 +- 「继续查询任务」只恢复当前项目中已保存 `task_id` 且标记为可恢复的 cmhub 生图任务,包括已提交、生成中及下载/保存中断后可继续处理的任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。上游已终态失败、任务过期或尚未取得 `task_id` 即停止的任务会明确提示需要重新生成,可能产生新的计费;已有 `task_id` 后被用户停止的任务仍可继续查询。 - 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 - 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 - 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。 diff --git a/docs/tasks/T-613.md b/docs/tasks/T-613.md index d4bd465..3f67846 100644 --- a/docs/tasks/T-613.md +++ b/docs/tasks/T-613.md @@ -3,7 +3,7 @@ id: T-613 title: AI工场照片池与失败生图任务分层展示 phase: 7 deps: [T-594, T-607, T-612] -status: TODO +status: DONE created: 2026-07-13 --- @@ -39,7 +39,7 @@ created: 2026-07-13 ### 3. 恢复动作必须符合计费语义 - 继续保留项目级「继续查询任务」入口。对于已保存 `task_id` 的 job,继续查询只能 poll/download 既有任务,不能再次 submit、不能重复扣点;T-594 既有口径不变。 -- 状态卡应明确说明恢复方式:可恢复 job 显示“可继续查询”;已收到终态上游失败、过期或用户停止的 job 显示“需要重新生成,可能再次扣点”。 +- 状态卡应明确说明恢复方式:已保存远端 `task_id` 且恢复语义为可恢复的 job 显示“可继续查询”;已收到终态上游失败、过期,或尚未取得 `task_id` 即被用户停止的 job 显示“需要重新生成,可能再次扣点”。用户在已保存 `task_id` 后停止的 job 保留“可继续查询”,因为恢复只会查询既有远端任务,不会重新提交。 - 本任务不把“重新生成”伪装成无成本重试,也不在卡片点击时自动创建新 job。用户需要重新生成时,仍从现有生成设置显式发起新一轮。 - 如现有持久化字段不足以可靠区分“可继续查询”和“必须重新生成”,先补充最小、可迁移的 job 恢复语义字段或由服务层提供明确分类;不得仅靠匹配错误文案字符串猜测。 @@ -60,7 +60,7 @@ created: 2026-07-13 - 已提交、生成中、取消、过期等未产出图片任务也在状态区以完整中文状态显示;没有此类任务时状态区隐藏。 - 任务成功并保存图片后,状态卡消失,图片进入照片池;失败记录不会悄悄丢失。 - 对保存了 `task_id` 的可恢复任务,用户能明确知道可用「继续查询任务」恢复,且恢复不会重复 submit 或扣点。 -- 对终态失败/过期/停止任务,界面明确提示需重新生成且可能再次扣点;不把它错误标记为可恢复。 +- 对终态失败/过期,以及未取得 `task_id` 即停止的任务,界面明确提示需重新生成且可能再次扣点;不把它错误标记为可恢复。 - 原因摘要、日志和 tooltip 不泄露 cmhub URL、接口路径、密钥、Cookie、完整堆栈或英文技术错误。 - 不影响 T-612 的生图运行态分级、T-608 的原图下载队列、T-594 的重启续查和计费保护、终选拖拽以及导出。 @@ -98,4 +98,7 @@ git diff --check ## 执行记录 -(完成后记录实现、验证命令与人工验收结果。) +- 2026-07-13:照片池改为仅显示本地可用资产;新增独立「生成任务」区,展示等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并以状态语义色、中文状态、脱敏原因、恢复方式和已有计费信息说明任务情况。任务卡不可作为源图或拖入终选;成功产出可用图片后自动从任务区移入照片池。 +- 2026-07-13:`image_studio_jobs` 增加可迁移的 `recovery_action` 字段。提交成功后标记为可继续查询;下载/保存中断保留继续查询资格;远端终态失败、过期及未取得远端任务即停止时标记为需要重新生成;成功任务标记为无需恢复。`继续查询任务` 仅处理可恢复的已有 `task_id`,不会重复提交或扣点。 +- 2026-07-13:补充照片池/任务区隔离、项目切换、成功转入照片池、中文状态与危险语义色、原因脱敏、恢复分类、旧 SQLite 迁移和续查不重复提交的自动化测试。 +- 验证:在干净 worktree 运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(391 项通过)和 `git diff --check`,均通过。未做真实 cmhub 生图人工验收。 diff --git a/tests/test_gui.py b/tests/test_gui.py index affbb4c..c00fca8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -928,19 +928,143 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab._select_project(project.id) self.assertEqual("继续查询任务", tab.resume_button.text()) + self.assertEqual(1, tab.pool_grid.count()) + self.assertTrue( + all( + tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "asset" + for row in range(tab.pool_grid.count()) + ) + ) + self.assertFalse(tab.job_section.isHidden()) statuses = [ - tab.pool_grid.item(row).text() - for row in range(tab.pool_grid.count()) - if tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "job" + tab.job_grid.item(row).text() + for row in range(tab.job_grid.count()) ] self.assertEqual(1, len(statuses)) self.assertIn("已提交", statuses[0]) + self.assertIn("可继续查询", statuses[0]) self.assertIn("扣点2", statuses[0]) self.assertIn("余额88", statuses[0]) self.assertIn("call_id=call-1", statuses[0]) self.assert_removed(temp_dir) + def test_image_studio_tab_separates_usable_assets_from_job_states(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + other_project = image_studio.create_or_get_project( + account_alias="alias-b", + account_slug="alias_b", + item_id="51100639511", + path=cfg["db_path"], + ) + source = image_studio.add_asset( + project.id, + "original", + local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), + path=cfg["db_path"], + ) + resumable = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="resume-job", + path=cfg["db_path"], + ) + image_studio.set_job_submitted(resumable.id, "remote-resume", path=cfg["db_path"]) + image_studio.update_job_status( + resumable.id, + "failed", + error="下载失败:https://secret.example.com/api/v1/image?token=private", + path=cfg["db_path"], + ) + terminal = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="terminal-job", + path=cfg["db_path"], + ) + image_studio.set_job_submitted(terminal.id, "remote-terminal", path=cfg["db_path"]) + image_studio.update_job_status( + terminal.id, + "expired", + error="上游任务已过期", + recovery_action=image_studio.JOB_RECOVERY_REGENERATE, + path=cfg["db_path"], + ) + stopped = image_studio.create_job( + project.id, + source_asset_id=source.id, + task_key="stopped-job", + path=cfg["db_path"], + ) + image_studio.update_job_status( + stopped.id, + "cancelled", + error="用户停止", + recovery_action=image_studio.JOB_RECOVERY_REGENERATE, + path=cfg["db_path"], + ) + + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(project.id) + + self.assertEqual(1, tab.pool_grid.count()) + self.assertTrue( + all( + tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "asset" + for row in range(tab.pool_grid.count()) + ) + ) + self.assertFalse(tab.job_section.isHidden()) + self.assertEqual(3, tab.job_grid.count()) + job_items = [tab.job_grid.item(row) for row in range(tab.job_grid.count())] + self.assertTrue( + all(item.data(gui.Qt.UserRole)["draggable"] is False for item in job_items) + ) + details = "\n".join(item.text() for item in job_items) + tooltips = "\n".join(item.toolTip() for item in job_items) + self.assertIn("生成失败", details) + self.assertIn("任务过期", details) + self.assertIn("已停止", details) + self.assertIn("可继续查询", details) + self.assertIn("需要重新生成,可能再次扣点", details) + self.assertNotIn("https://", details) + self.assertNotIn("secret.example", tooltips) + failed_item = next(item for item in job_items if "生成失败" in item.text()) + self.assertEqual("#ffebe9", failed_item.background().color().name()) + + tab._select_project(other_project.id) + self.assertEqual(0, tab.job_grid.count()) + self.assertTrue(tab.job_section.isHidden()) + + tab._select_project(project.id) + output = image_studio.add_asset( + project.id, + "generated_main", + parent_asset_id=source.id, + local_path=self.write_test_image(os.path.join(temp_dir, "generated.jpg")), + path=cfg["db_path"], + ) + image_studio.update_job_status( + resumable.id, + "succeeded", + output_asset_id=output.id, + path=cfg["db_path"], + ) + tab.refresh_project_assets() + self.assertEqual(2, tab.pool_grid.count()) + self.assertEqual(2, tab.job_grid.count()) + + self.assert_removed(temp_dir) + def test_image_studio_generation_log_uses_cmhub_tier_summary(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index 90510de..bb53be5 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -62,6 +62,11 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "deleted_at", }.issubset(projects_columns) ) + jobs_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(image_studio_jobs)").fetchall() + } + self.assertIn("recovery_action", jobs_columns) finally: conn.close() @@ -305,6 +310,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertEqual("stable-task-key", job.task_key) self.assertEqual("pending", job.status) self.assertEqual("cmhub", job.provider) + self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, job.recovery_action) submitted = image_studio.set_job_submitted( job.id, @@ -317,6 +323,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertEqual("submitted", submitted.status) self.assertEqual("cmhub-task-1", submitted.task_id) self.assertEqual(2, submitted.points_cost) + self.assertEqual(image_studio.JOB_RECOVERY_RESUME, submitted.recovery_action) self.assertEqual([job.id], [item.id for item in image_studio.list_resumable_jobs(path=db_path)]) running = image_studio.update_job_status( @@ -346,9 +353,30 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assertEqual("succeeded", succeeded.status) self.assertEqual(output_asset.id, succeeded.output_asset_id) self.assertEqual(96, succeeded.points_balance) + self.assertEqual(image_studio.JOB_RECOVERY_NONE, succeeded.recovery_action) self.assertIsNotNone(succeeded.finished_at) self.assertEqual([], image_studio.list_resumable_jobs(path=db_path)) + terminal = image_studio.create_job( + project.id, + source_asset_id=source_asset.id, + task_key="terminal-task-key", + path=db_path, + ) + image_studio.set_job_submitted(terminal.id, "cmhub-terminal", path=db_path) + terminal = image_studio.update_job_status( + terminal.id, + "failed", + error="上游生成失败", + recovery_action=image_studio.JOB_RECOVERY_REGENERATE, + path=db_path, + ) + self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, terminal.recovery_action) + self.assertEqual( + [], + image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True), + ) + with self.assertRaises(db.DbError): image_studio.create_job( project.id, @@ -358,6 +386,73 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_init_db_migrates_legacy_image_studio_job_recovery_action(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "legacy.db") + conn = db.connect(db_path) + try: + conn.execute( + """ + CREATE TABLE image_studio_jobs ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL, + source_asset_id INTEGER, + output_asset_id INTEGER, + generation_source TEXT NOT NULL DEFAULT 'cmhub', + provider TEXT NOT NULL DEFAULT 'cmhub', + job_type TEXT NOT NULL, + task_key TEXT NOT NULL UNIQUE, + task_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + prompt TEXT, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + points_cost INTEGER, + points_balance INTEGER, + call_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + submitted_at TEXT, + finished_at TEXT + ) + """ + ) + conn.executemany( + """ + INSERT INTO image_studio_jobs + (id, project_id, job_type, task_key, task_id, status, created_at, updated_at) + VALUES (?, 1, 'main', ?, ?, ?, '2026-07-13T00:00:00', '2026-07-13T00:00:00') + """, + [ + (1, "legacy-submitted", "task-submitted", "submitted"), + (2, "legacy-running", "task-running", "running"), + (3, "legacy-failed", "task-failed", "failed"), + (4, "legacy-success", "task-success", "succeeded"), + ], + ) + conn.commit() + finally: + conn.close() + + db.init_db(db_path) + conn = db.connect(db_path) + try: + recovery_actions = { + row["task_key"]: row["recovery_action"] + for row in conn.execute( + "SELECT task_key, recovery_action FROM image_studio_jobs ORDER BY id" + ).fetchall() + } + finally: + conn.close() + + self.assertEqual(image_studio.JOB_RECOVERY_RESUME, recovery_actions["legacy-submitted"]) + self.assertEqual(image_studio.JOB_RECOVERY_RESUME, recovery_actions["legacy-running"]) + self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, recovery_actions["legacy-failed"]) + self.assertEqual(image_studio.JOB_RECOVERY_NONE, recovery_actions["legacy-success"]) + + self.assert_removed(temp_dir) + def test_selections_are_consecutive_unique_and_replaceable(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index ed7d6f4..26c3e5c 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -256,6 +256,9 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): path=cfg["db_path"], ) + failed_job = image_studio.get_job(job.id, path=cfg["db_path"]) + self.assertEqual(image_studio.JOB_RECOVERY_RESUME, failed_job.recovery_action) + resumable = image_studio.list_resumable_jobs( path=cfg["db_path"], project_id=project.id, @@ -291,6 +294,7 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assertEqual("succeeded", updated.status) self.assertEqual("cmhub-task-download", updated.task_id) self.assertEqual("call-download", updated.call_id) + self.assertEqual(image_studio.JOB_RECOVERY_NONE, updated.recovery_action) assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]) self.assertEqual(1, len(assets)) @@ -339,6 +343,16 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assertEqual(1, summary["failed"]) assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]) self.assertEqual(1, len(assets)) + failed_jobs = [ + job + for job in image_studio.list_resumable_jobs( + path=cfg["db_path"], + project_id=project.id, + include_failed_downloads=True, + ) + if job.status == "failed" + ] + self.assertEqual([], failed_jobs) self.assert_removed(temp_dir)