feat(ai-studio): separate image pool from task states
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
+144
-40
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||||
|
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 = [
|
statuses = [
|
||||||
tab.pool_grid.item(row).text()
|
tab.job_grid.item(row).text()
|
||||||
for row in range(tab.pool_grid.count())
|
for row in range(tab.job_grid.count())
|
||||||
if tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "job"
|
|
||||||
]
|
]
|
||||||
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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user