feat(ai-studio): separate image pool from task states

This commit is contained in:
chengma
2026-07-13 09:54:08 +08:00
parent 42fe233ed9
commit b1c050f633
9 changed files with 500 additions and 59 deletions
+33
View File
@@ -343,6 +343,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs (
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
prompt TEXT, prompt TEXT,
error TEXT, error TEXT,
recovery_action TEXT NOT NULL DEFAULT 'regenerate',
attempts INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
points_cost INTEGER, points_cost INTEGER,
points_balance INTEGER, points_balance INTEGER,
@@ -477,6 +478,7 @@ def init_db(path=None, conn=None) -> None:
_ensure_batch_delete_columns(database) _ensure_batch_delete_columns(database)
_ensure_task_image_task_columns(database) _ensure_task_image_task_columns(database)
_ensure_task_cover_reset_columns(database) _ensure_task_cover_reset_columns(database)
_ensure_image_studio_job_recovery_columns(database)
def _ensure_batch_delete_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: if "cover_reset_at" not in columns:
database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") 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: 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] 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] files = [os.path.abspath(file_path) for file_path in file_paths]
+142 -38
View File
@@ -7,7 +7,7 @@ import os
from PySide6.QtCore import QObject, QMimeData, QSize, Signal from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy 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 .. import file_manager
from ..widgets import * from ..widgets import *
from ..workers import ( from ..workers import (
@@ -266,12 +266,12 @@ class ImageStudioTab(QWidget):
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"] PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
JOB_STATUS_LABELS = { JOB_STATUS_LABELS = {
"pending": "排队中", "pending": "等待提交",
"submitted": "已提交", "submitted": "已提交",
"running": "生成中", "running": "生成中",
"succeeded": "成功", "succeeded": "成功",
"failed": "失败", "failed": "生成失败",
"expired": "已过期", "expired": "任务过期",
"cancelled": "已停止", "cancelled": "已停止",
} }
@@ -454,6 +454,9 @@ class ImageStudioTab(QWidget):
pool_title = QLabel("照片池") pool_title = QLabel("照片池")
pool_title.setObjectName("imageStudioSectionTitle") pool_title.setObjectName("imageStudioSectionTitle")
pool_header.addWidget(pool_title) 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) pool_header.addStretch(1)
self.source_label = QLabel("源图:未选择") self.source_label = QLabel("源图:未选择")
self.source_label.setObjectName("imageStudioSourceLabel") self.source_label.setObjectName("imageStudioSourceLabel")
@@ -466,6 +469,30 @@ class ImageStudioTab(QWidget):
self.pool_grid.setGridSize(QSize(108, 124)) self.pool_grid.setGridSize(QSize(108, 124))
self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu) self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.pool_grid, 2) 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 return panel
def _build_generation_panel(self): def _build_generation_panel(self):
@@ -1110,6 +1137,7 @@ class ImageStudioTab(QWidget):
self._refresh_project_summary() self._refresh_project_summary()
self._fill_original_grid() self._fill_original_grid()
self._fill_pool_grid() self._fill_pool_grid()
self._fill_job_grid()
self._refresh_selection_labels() self._refresh_selection_labels()
self._refresh_source_label() self._refresh_source_label()
@@ -1148,40 +1176,71 @@ class ImageStudioTab(QWidget):
self._refresh_original_download_label() self._refresh_original_download_label()
def _fill_pool_grid(self): def _fill_pool_grid(self):
rows = [] assets = [
for asset in self.assets: asset
if asset.kind not in {"original", "generated_main", "generated_detail"} or not _asset_is_usable(asset): for asset in self.assets
continue if asset.kind in {"original", "generated_main", "generated_detail"} and _asset_is_usable(asset)
rows.append(("asset", asset)) ]
for job in self.jobs:
if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}:
rows.append(("job", job))
self.pool_grid.clear() self.pool_grid.clear()
for row_type, obj in rows: for asset in assets:
if row_type == "asset": draggable = _asset_is_usable(asset)
draggable = _asset_is_usable(obj) data = {"type": "asset", "asset_id": int(asset.id), "draggable": draggable}
data = {"type": "asset", "asset_id": int(obj.id), "draggable": draggable} text = f"{_asset_badge(asset.kind)} #{asset.id}\n{asset.aspect_ratio or '比例未知'}"
text = f"{_asset_badge(obj.kind)} #{obj.id}\n{obj.aspect_ratio or '比例未知'}"
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。" tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
else:
data = {"type": "job", "job_id": int(obj.id)}
text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}"
tooltip = "生图任务已保存,可继续查询。"
item = QListWidgetItem(text) item = QListWidgetItem(text)
item.setData(Qt.UserRole, data) item.setData(Qt.UserRole, data)
item.setSizeHint(QSize(108, 124)) item.setSizeHint(QSize(108, 124))
item.setToolTip(tooltip) item.setToolTip(tooltip)
if row_type == "asset": item.setIcon(_asset_icon(asset, _asset_badge(asset.kind), size=QSize(86, 86)))
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind), size=QSize(86, 86))) if asset.id == self.selected_source_asset_id:
if obj.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff")) 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))
self.pool_grid.addItem(item) 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): def _selection_asset_ids(self, selection_type):
return [ return [
@@ -1520,6 +1579,7 @@ class ImageStudioTab(QWidget):
self.prompt_edit.blockSignals(False) self.prompt_edit.blockSignals(False)
self._fill_original_grid() self._fill_original_grid()
self._fill_pool_grid() self._fill_pool_grid()
self._fill_job_grid()
self._refresh_selection_labels() self._refresh_selection_labels()
self._refresh_source_label() self._refresh_source_label()
self._refresh_project_summary() self._refresh_project_summary()
@@ -2019,6 +2079,7 @@ class ImageStudioTab(QWidget):
self.project_table.setEnabled(not running) self.project_table.setEnabled(not running)
self.original_grid.setEnabled(not running) self.original_grid.setEnabled(not running)
self.pool_grid.setEnabled(not running or generation_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.main_selection_list.setEnabled(not running or generation_running)
self.detail_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) 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 = { label = {
"pending": "排", "pending": "…",
"submitted": "提", "submitted": "…",
"running": "生", "running": "…",
"failed": "败", "failed": "!",
"expired": "过", "expired": "!",
"cancelled": "停", "cancelled": "停",
}.get(str(status or ""), "任") }.get(str(status or ""), "?")
color = { color = {
"failed": "#ffebe9", "failed": "#ffebe9",
"expired": "#ffebe9", "expired": "#ffebe9",
@@ -2246,7 +2307,7 @@ def _job_icon(status):
"submitted": "#fff8c5", "submitted": "#fff8c5",
"pending": "#f6f8fa", "pending": "#f6f8fa",
}.get(str(status or ""), "#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): 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): def _job_status_text(job, labels):
parts = [labels.get(job.status, job.status)] 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: if job.points_cost is not None:
parts.append(f"扣点{job.points_cost}") parts.append(f"扣点{job.points_cost}")
if job.points_balance is not None: if job.points_balance is not None:
@@ -2344,3 +2419,32 @@ def _job_status_text(job, labels):
if job.call_id: if job.call_id:
parts.append(f"call_id={job.call_id}") parts.append(f"call_id={job.call_id}")
return ",".join(parts) 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 "")
+32 -5
View File
@@ -20,6 +20,14 @@ ASSET_STATUS_MISSING = "missing"
ASSET_STATUSES = {ASSET_STATUS_AVAILABLE, ASSET_STATUS_MISSING} ASSET_STATUSES = {ASSET_STATUS_AVAILABLE, ASSET_STATUS_MISSING}
JOB_STATUSES = {"pending", "submitted", "running", "succeeded", "failed", "expired", "cancelled"} JOB_STATUSES = {"pending", "submitted", "running", "succeeded", "failed", "expired", "cancelled"}
JOB_RESUMABLE_STATUSES = {"submitted", "running"} 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"} SELECTION_TYPES = {"main", "detail"}
@@ -70,6 +78,7 @@ class ImageStudioJob:
status: str status: str
prompt: Optional[str] prompt: Optional[str]
error: Optional[str] error: Optional[str]
recovery_action: str
attempts: int attempts: int
points_cost: Optional[int] points_cost: Optional[int]
points_balance: 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 UPDATE image_studio_jobs
SET task_id = ?, SET task_id = ?,
status = 'submitted', status = 'submitted',
recovery_action = ?,
call_id = ?, call_id = ?,
points_cost = ?, points_cost = ?,
points_balance = ?, points_balance = ?,
@@ -684,7 +694,16 @@ def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points
updated_at = ? updated_at = ?
WHERE id = ? 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) return get_job(job_id, conn=database)
@@ -696,12 +715,17 @@ def update_job_status(
error=None, error=None,
output_asset_id=None, output_asset_id=None,
points_balance=None, points_balance=None,
recovery_action=None,
increment_attempts=False, increment_attempts=False,
path=None, path=None,
conn=None, conn=None,
): ):
if str(status) not in JOB_STATUSES: if str(status) not in JOB_STATUSES:
raise db.DbError("AI工场任务状态无效") 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() now = _now()
terminal = str(status) in {"succeeded", "failed", "expired", "cancelled"} terminal = str(status) in {"succeeded", "failed", "expired", "cancelled"}
with _connection(conn, path) as database: with _connection(conn, path) as database:
@@ -716,6 +740,7 @@ def update_job_status(
UPDATE image_studio_jobs UPDATE image_studio_jobs
SET status = ?, SET status = ?,
error = ?, error = ?,
recovery_action = COALESCE(?, recovery_action),
output_asset_id = COALESCE(?, output_asset_id), output_asset_id = COALESCE(?, output_asset_id),
points_balance = COALESCE(?, points_balance), points_balance = COALESCE(?, points_balance),
attempts = attempts + ?, attempts = attempts + ?,
@@ -726,6 +751,7 @@ def update_job_status(
( (
str(status), str(status),
error, error,
recovery_action,
output_asset_id, output_asset_id,
points_balance, points_balance,
1 if increment_attempts else 0, 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: if include_failed_downloads:
clauses = [ clauses = [
"task_id IS NOT NULL", "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: else:
clauses = ["status IN (?, ?)", "task_id IS NOT NULL"] clauses = ["status IN (?, ?)", "task_id IS NOT NULL", "recovery_action = ?"]
params = ["submitted", "running"] params = ["submitted", "running", JOB_RECOVERY_RESUME]
if project_id is not None: if project_id is not None:
clauses.append("project_id = ?") clauses.append("project_id = ?")
params.append(int(project_id)) params.append(int(project_id))
+46 -5
View File
@@ -194,7 +194,13 @@ def run_jobs(
if should_stop(): if should_stop():
for future, job in list(futures.items()): for future, job in list(futures.items()):
if future.cancel(): 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) futures.pop(future, None)
record({"job": job, "status": "cancelled", "error": "用户停止"}) record({"job": job, "status": "cancelled", "error": "用户停止"})
return summary 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: if job is None:
raise ImageStudioGenerationError("AI工场生图任务不存在") raise ImageStudioGenerationError("AI工场生图任务不存在")
if should_stop(): 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": "用户停止"} return {"job": updated, "status": "cancelled", "error": "用户停止"}
project = image_studio.get_project(job.project_id, path=db_path) project = image_studio.get_project(job.project_id, path=db_path)
source_asset = image_studio.get_asset(job.source_asset_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: 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": "项目或源图不存在"} return {"job": updated, "status": "failed", "error": "项目或源图不存在"}
try: try:
image_studio.update_job_status(job.id, "running", path=db_path) 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"} return {"job": updated, "asset": asset, "status": "succeeded"}
except Exception as exc: except Exception as exc:
status = "cancelled" if "停止" in str(exc) else "failed" 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)}) _notify(on_event, {"job_id": job.id, "step": "job_done", "result": status, "detail": str(exc)})
return {"job": updated, "status": status, "error": 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"}: if status in {"failed", "expired"}:
error = data.get("error") if isinstance(data.get("error"), dict) else {} error = data.get("error") if isinstance(data.get("error"), dict) else {}
message = str(error.get("message") or error.get("code") or status) 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(message)
raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误") raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误")
@@ -394,3 +425,13 @@ def _raise_if_stopped(should_stop):
stopped = False stopped = False
if stopped: if stopped:
raise ImageStudioGenerationError("用户已停止,已提交任务可稍后继续查询") 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
+2 -2
View File
@@ -193,10 +193,10 @@
- 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。 - 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。
- 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。 - 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。
- 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。 - 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。
- 照片池展示原图、生成主图、生成详情图和在途/失败任务状态;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。 - 照片池只展示本地文件存在且可解码使用的原图、生成主图和生成详情图;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。照片池附近的「生成任务」区单独展示尚未产出可用图片的等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并显示脱敏后的中文原因摘要、恢复方式和已有计费信息;任务卡不能设为源图或拖入终选。
- 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 - 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。
- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 - 生图固定走 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 中的持久化顺序。 - 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。
- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。 - 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。
+7 -4
View File
@@ -3,7 +3,7 @@ id: T-613
title: AI工场照片池与失败生图任务分层展示 title: AI工场照片池与失败生图任务分层展示
phase: 7 phase: 7
deps: [T-594, T-607, T-612] deps: [T-594, T-607, T-612]
status: TODO status: DONE
created: 2026-07-13 created: 2026-07-13
--- ---
@@ -39,7 +39,7 @@ created: 2026-07-13
### 3. 恢复动作必须符合计费语义 ### 3. 恢复动作必须符合计费语义
- 继续保留项目级「继续查询任务」入口。对于已保存 `task_id` 的 job,继续查询只能 poll/download 既有任务,不能再次 submit、不能重复扣点;T-594 既有口径不变。 - 继续保留项目级「继续查询任务」入口。对于已保存 `task_id` 的 job,继续查询只能 poll/download 既有任务,不能再次 submit、不能重复扣点;T-594 既有口径不变。
- 状态卡应明确说明恢复方式:可恢复 job 显示“可继续查询”;已收到终态上游失败、过期或用户停止的 job 显示“需要重新生成,可能再次扣点”。 - 状态卡应明确说明恢复方式:已保存远端 `task_id` 且恢复语义为可恢复的 job 显示“可继续查询”;已收到终态上游失败、过期,或尚未取得 `task_id` 即被用户停止的 job 显示“需要重新生成,可能再次扣点”。用户在已保存 `task_id` 后停止的 job 保留“可继续查询”,因为恢复只会查询既有远端任务,不会重新提交。
- 本任务不把“重新生成”伪装成无成本重试,也不在卡片点击时自动创建新 job。用户需要重新生成时,仍从现有生成设置显式发起新一轮。 - 本任务不把“重新生成”伪装成无成本重试,也不在卡片点击时自动创建新 job。用户需要重新生成时,仍从现有生成设置显式发起新一轮。
- 如现有持久化字段不足以可靠区分“可继续查询”和“必须重新生成”,先补充最小、可迁移的 job 恢复语义字段或由服务层提供明确分类;不得仅靠匹配错误文案字符串猜测。 - 如现有持久化字段不足以可靠区分“可继续查询”和“必须重新生成”,先补充最小、可迁移的 job 恢复语义字段或由服务层提供明确分类;不得仅靠匹配错误文案字符串猜测。
@@ -60,7 +60,7 @@ created: 2026-07-13
- 已提交、生成中、取消、过期等未产出图片任务也在状态区以完整中文状态显示;没有此类任务时状态区隐藏。 - 已提交、生成中、取消、过期等未产出图片任务也在状态区以完整中文状态显示;没有此类任务时状态区隐藏。
- 任务成功并保存图片后,状态卡消失,图片进入照片池;失败记录不会悄悄丢失。 - 任务成功并保存图片后,状态卡消失,图片进入照片池;失败记录不会悄悄丢失。
- 对保存了 `task_id` 的可恢复任务,用户能明确知道可用「继续查询任务」恢复,且恢复不会重复 submit 或扣点。 - 对保存了 `task_id` 的可恢复任务,用户能明确知道可用「继续查询任务」恢复,且恢复不会重复 submit 或扣点。
- 对终态失败/过期/停止任务,界面明确提示需重新生成且可能再次扣点;不把它错误标记为可恢复。 - 对终态失败/过期,以及未取得 `task_id` 即停止的任务,界面明确提示需重新生成且可能再次扣点;不把它错误标记为可恢复。
- 原因摘要、日志和 tooltip 不泄露 cmhub URL、接口路径、密钥、Cookie、完整堆栈或英文技术错误。 - 原因摘要、日志和 tooltip 不泄露 cmhub URL、接口路径、密钥、Cookie、完整堆栈或英文技术错误。
- 不影响 T-612 的生图运行态分级、T-608 的原图下载队列、T-594 的重启续查和计费保护、终选拖拽以及导出。 - 不影响 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 生图人工验收。
+127 -3
View File
@@ -928,19 +928,143 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab._select_project(project.id) tab._select_project(project.id)
self.assertEqual("继续查询任务", tab.resume_button.text()) self.assertEqual("继续查询任务", tab.resume_button.text())
statuses = [ self.assertEqual(1, tab.pool_grid.count())
tab.pool_grid.item(row).text() self.assertTrue(
all(
tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "asset"
for row in range(tab.pool_grid.count()) for row in range(tab.pool_grid.count())
if tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "job" )
)
self.assertFalse(tab.job_section.isHidden())
statuses = [
tab.job_grid.item(row).text()
for row in range(tab.job_grid.count())
] ]
self.assertEqual(1, len(statuses)) self.assertEqual(1, len(statuses))
self.assertIn("已提交", statuses[0]) self.assertIn("已提交", statuses[0])
self.assertIn("可继续查询", statuses[0])
self.assertIn("扣点2", statuses[0]) self.assertIn("扣点2", statuses[0])
self.assertIn("余额88", statuses[0]) self.assertIn("余额88", statuses[0])
self.assertIn("call_id=call-1", statuses[0]) self.assertIn("call_id=call-1", statuses[0])
self.assert_removed(temp_dir) 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): def test_image_studio_generation_log_uses_cmhub_tier_summary(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)
+95
View File
@@ -62,6 +62,11 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
"deleted_at", "deleted_at",
}.issubset(projects_columns) }.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: finally:
conn.close() conn.close()
@@ -305,6 +310,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assertEqual("stable-task-key", job.task_key) self.assertEqual("stable-task-key", job.task_key)
self.assertEqual("pending", job.status) self.assertEqual("pending", job.status)
self.assertEqual("cmhub", job.provider) self.assertEqual("cmhub", job.provider)
self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, job.recovery_action)
submitted = image_studio.set_job_submitted( submitted = image_studio.set_job_submitted(
job.id, job.id,
@@ -317,6 +323,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assertEqual("submitted", submitted.status) self.assertEqual("submitted", submitted.status)
self.assertEqual("cmhub-task-1", submitted.task_id) self.assertEqual("cmhub-task-1", submitted.task_id)
self.assertEqual(2, submitted.points_cost) 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)]) self.assertEqual([job.id], [item.id for item in image_studio.list_resumable_jobs(path=db_path)])
running = image_studio.update_job_status( running = image_studio.update_job_status(
@@ -346,9 +353,30 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assertEqual("succeeded", succeeded.status) self.assertEqual("succeeded", succeeded.status)
self.assertEqual(output_asset.id, succeeded.output_asset_id) self.assertEqual(output_asset.id, succeeded.output_asset_id)
self.assertEqual(96, succeeded.points_balance) self.assertEqual(96, succeeded.points_balance)
self.assertEqual(image_studio.JOB_RECOVERY_NONE, succeeded.recovery_action)
self.assertIsNotNone(succeeded.finished_at) self.assertIsNotNone(succeeded.finished_at)
self.assertEqual([], image_studio.list_resumable_jobs(path=db_path)) 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): with self.assertRaises(db.DbError):
image_studio.create_job( image_studio.create_job(
project.id, project.id,
@@ -358,6 +386,73 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_selections_are_consecutive_unique_and_replaceable(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
+14
View File
@@ -256,6 +256,9 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
path=cfg["db_path"], 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( resumable = image_studio.list_resumable_jobs(
path=cfg["db_path"], path=cfg["db_path"],
project_id=project.id, project_id=project.id,
@@ -291,6 +294,7 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
self.assertEqual("succeeded", updated.status) self.assertEqual("succeeded", updated.status)
self.assertEqual("cmhub-task-download", updated.task_id) self.assertEqual("cmhub-task-download", updated.task_id)
self.assertEqual("call-download", updated.call_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"]) assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])
self.assertEqual(1, len(assets)) self.assertEqual(1, len(assets))
@@ -339,6 +343,16 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
self.assertEqual(1, summary["failed"]) self.assertEqual(1, summary["failed"])
assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]) assets = image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])
self.assertEqual(1, len(assets)) 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) self.assert_removed(temp_dir)