dsh-plugin-image-workbench 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1329 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
package/server/history.py
CHANGED
|
@@ -1,575 +1,575 @@
|
|
|
1
|
-
"""生成历史:一条任务一行 JSONL,落盘后可跨重启查询。
|
|
2
|
-
|
|
3
|
-
图片本身已经在 workbench/images/{job_id}.png,这里只存索引与元信息,
|
|
4
|
-
所以历史列表是纯读文件,不会因为图片多而变慢。
|
|
5
|
-
|
|
6
|
-
两层结构:
|
|
7
|
-
- 一行 = 一次出图(job),是历史的原子单位;
|
|
8
|
-
- 一次点击「批量出图」会提交 N 个 job,它们共用同一个 task_id。
|
|
9
|
-
前端历史面板按 task 分组展示,默认折叠,展开才看这一批的图。
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import re
|
|
16
|
-
import threading
|
|
17
|
-
import time
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
import imaging
|
|
21
|
-
|
|
22
|
-
MAX_SCAN = 4000 # 最多回读多少行,防止文件无限增长后启动变慢
|
|
23
|
-
|
|
24
|
-
# 标题开头的序号:「03 24小时冷冻」→ 3
|
|
25
|
-
_TITLE_NO = re.compile(r"^\s*0*(\d{1,3})(?=[\s、..::\-—_|/))]|$)")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _title_no(s: str) -> int | None:
|
|
29
|
-
m = _TITLE_NO.match(s or "")
|
|
30
|
-
return int(m.group(1)) if m else None
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _order_items(items: list[dict]) -> list[dict]:
|
|
34
|
-
"""组内排序:seq 优先,其次标题里的序号,最后才按时间。
|
|
35
|
-
|
|
36
|
-
created 只有秒级精度,一次批量出图 8 条基本全落在同一两秒里,只按时间
|
|
37
|
-
排会退化成按随机 id 排,载入历史后顺序就成了 03 01 02 06 04 08 05 07。
|
|
38
|
-
"""
|
|
39
|
-
if not items:
|
|
40
|
-
return items
|
|
41
|
-
if all(isinstance(x.get("seq"), int) for x in items):
|
|
42
|
-
return sorted(items, key=lambda x: (x["seq"], int(x.get("created") or 0)))
|
|
43
|
-
nums = [_title_no(x.get("title") or x.get("topic") or "") for x in items]
|
|
44
|
-
if all(v is not None for v in nums) and len(set(nums)) == len(nums):
|
|
45
|
-
return [x for _, x in sorted(zip(nums, items), key=lambda p: p[0])]
|
|
46
|
-
return sorted(items, key=lambda x: (int(x.get("created") or 0), str(x["id"])))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
_DROP = object() # 「这个值没法写进 JSON,丢掉」的哨兵,和合法的 None 区分开
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _clean(v):
|
|
53
|
-
"""递归洗成纯 JSON 基础类型:保留 list/dict(失败轨迹就是这么存的),丢掉异常对象。"""
|
|
54
|
-
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
55
|
-
return v
|
|
56
|
-
if isinstance(v, dict):
|
|
57
|
-
out = {}
|
|
58
|
-
for k, x in v.items():
|
|
59
|
-
c = _clean(x)
|
|
60
|
-
if c is not _DROP:
|
|
61
|
-
out[str(k)] = c
|
|
62
|
-
return out
|
|
63
|
-
if isinstance(v, (list, tuple)):
|
|
64
|
-
out = []
|
|
65
|
-
for x in v:
|
|
66
|
-
c = _clean(x)
|
|
67
|
-
if c is not _DROP:
|
|
68
|
-
out.append(c)
|
|
69
|
-
return out
|
|
70
|
-
return _DROP
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _safe(d: dict) -> dict:
|
|
74
|
-
"""只保留可 JSON 序列化的内容,避免 job 里混进 Task / 异常对象。
|
|
75
|
-
|
|
76
|
-
以前只认 str/int/float/bool/None,顺手把 list/dict 也丢了,
|
|
77
|
-
导致 attempts(每次失败用的哪个号)压根存不进历史。
|
|
78
|
-
"""
|
|
79
|
-
out = {}
|
|
80
|
-
for k, v in d.items():
|
|
81
|
-
c = _clean(v)
|
|
82
|
-
if c is not _DROP:
|
|
83
|
-
out[k] = c
|
|
84
|
-
return out
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class History:
|
|
88
|
-
def __init__(self, path: Path, images_dir, thumbs_dir: Path | None = None,
|
|
89
|
-
finder=None) -> None:
|
|
90
|
-
self.path = path
|
|
91
|
-
# images_dir 可以是 Path,也可以是返回 Path 的函数:
|
|
92
|
-
# 用户在工作台里改了「生成图片保存目录」后,这里要立刻跟着走,不能等重启。
|
|
93
|
-
self._images = images_dir
|
|
94
|
-
# finder(name) 负责多目录兜底(换过保存目录后老图仍可读)
|
|
95
|
-
self._finder = finder
|
|
96
|
-
self.thumbs_dir = Path(thumbs_dir) if thumbs_dir else (
|
|
97
|
-
self.images_dir.parent / "thumbs")
|
|
98
|
-
self._lock = threading.Lock()
|
|
99
|
-
self._items: list[dict] = []
|
|
100
|
-
self._ids: set[str] = set()
|
|
101
|
-
self._load()
|
|
102
|
-
|
|
103
|
-
@property
|
|
104
|
-
def images_dir(self) -> Path:
|
|
105
|
-
d = self._images
|
|
106
|
-
return Path(d() if callable(d) else d)
|
|
107
|
-
|
|
108
|
-
# ---------- load / write ----------
|
|
109
|
-
def _load(self) -> None:
|
|
110
|
-
if not self.path.exists():
|
|
111
|
-
return
|
|
112
|
-
try:
|
|
113
|
-
lines = self.path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
114
|
-
except Exception:
|
|
115
|
-
return
|
|
116
|
-
rows: list[dict] = []
|
|
117
|
-
for ln in lines[-MAX_SCAN:]:
|
|
118
|
-
ln = ln.strip()
|
|
119
|
-
if not ln:
|
|
120
|
-
continue
|
|
121
|
-
try:
|
|
122
|
-
d = json.loads(ln)
|
|
123
|
-
except Exception:
|
|
124
|
-
continue
|
|
125
|
-
if isinstance(d, dict) and d.get("id"):
|
|
126
|
-
rows.append(d)
|
|
127
|
-
self._items = rows
|
|
128
|
-
self._ids = {r["id"] for r in rows}
|
|
129
|
-
|
|
130
|
-
def _append(self, rec: dict) -> None:
|
|
131
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
-
line = json.dumps(rec, ensure_ascii=False)
|
|
133
|
-
with self.path.open("a", encoding="utf-8") as f:
|
|
134
|
-
f.write(line + "\n")
|
|
135
|
-
|
|
136
|
-
# ---------- write ----------
|
|
137
|
-
def record(self, job: dict) -> None:
|
|
138
|
-
"""任务结束时调用一次。同一个 id 只记一次,重试不产生两条。"""
|
|
139
|
-
jid = str(job.get("id") or "")
|
|
140
|
-
if not jid:
|
|
141
|
-
return
|
|
142
|
-
result = job.get("result") or {}
|
|
143
|
-
rec = _safe({
|
|
144
|
-
"id": jid,
|
|
145
|
-
"task_id": job.get("task_id") or "",
|
|
146
|
-
"created": job.get("created") or int(time.time()),
|
|
147
|
-
"finished": job.get("finished") or int(time.time()),
|
|
148
|
-
"mode": job.get("mode") or "image",
|
|
149
|
-
"status": job.get("status") or "done",
|
|
150
|
-
"topic": job.get("topic") or "",
|
|
151
|
-
"title": job.get("title") or job.get("topic") or "",
|
|
152
|
-
"body": job.get("body") or "",
|
|
153
|
-
# 整篇正文(左侧「正文」框),载入历史时还原用;老记录没有这个字段
|
|
154
|
-
"doc_body": job.get("doc_body") or "",
|
|
155
|
-
# 这一批里的提交序号,0 起。并行出图完成顺序是乱的,靠它还原清单顺序
|
|
156
|
-
"seq": job.get("seq"),
|
|
157
|
-
"group": job.get("group") or "",
|
|
158
|
-
"account": job.get("account") or "",
|
|
159
|
-
"env_id": job.get("env_id"),
|
|
160
|
-
"style_id": job.get("style_id"),
|
|
161
|
-
"layout_id": job.get("layout_id"),
|
|
162
|
-
"color_id": job.get("color_id"),
|
|
163
|
-
"ratio": job.get("ratio"),
|
|
164
|
-
"ref_id": job.get("ref_id") or "",
|
|
165
|
-
# 出图模式(图文 / 摄影)与拍摄类型,载入历史时一起还原
|
|
166
|
-
"image_mode": job.get("image_mode") or "graphic-text",
|
|
167
|
-
"shoot_type": job.get("shoot_type") or "",
|
|
168
|
-
"prompt": job.get("prompt") or "",
|
|
169
|
-
# 失败/换号的完整轨迹:第几次、哪个号、什么错。成功那次也在里面,
|
|
170
|
-
# 前端据此显示「换 2 个号后成功」或「换了 3 个号仍未成功」
|
|
171
|
-
"attempts": job.get("attempts") or [],
|
|
172
|
-
"account_email": job.get("account_email") or "",
|
|
173
|
-
"image": result.get("image"),
|
|
174
|
-
"width": result.get("width"),
|
|
175
|
-
"height": result.get("height"),
|
|
176
|
-
"bytes": result.get("bytes"),
|
|
177
|
-
"error": job.get("error") or "",
|
|
178
|
-
})
|
|
179
|
-
with self._lock:
|
|
180
|
-
if jid in self._ids:
|
|
181
|
-
return
|
|
182
|
-
self._ids.add(jid)
|
|
183
|
-
self._items.append(rec)
|
|
184
|
-
try:
|
|
185
|
-
self._append(rec)
|
|
186
|
-
except Exception:
|
|
187
|
-
pass
|
|
188
|
-
|
|
189
|
-
# ---------- read ----------
|
|
190
|
-
def get(self, job_id: str) -> dict | None:
|
|
191
|
-
with self._lock:
|
|
192
|
-
return next((r for r in reversed(self._items) if r["id"] == job_id), None)
|
|
193
|
-
|
|
194
|
-
def image_path(self, job_id: str) -> Path | None:
|
|
195
|
-
rec = self.get(job_id)
|
|
196
|
-
if not rec or not rec.get("image"):
|
|
197
|
-
return None
|
|
198
|
-
name = str(rec["image"])
|
|
199
|
-
if self._finder:
|
|
200
|
-
return self._finder(name)
|
|
201
|
-
for d in (self.images_dir, self.images_dir.parent / "images"):
|
|
202
|
-
p = d / name
|
|
203
|
-
if p.is_file():
|
|
204
|
-
return p
|
|
205
|
-
return None
|
|
206
|
-
|
|
207
|
-
def _any_image(self, name: str) -> Path:
|
|
208
|
-
"""按文件名定位原图:交给 finder 做多目录兜底,没注入就按当前目录找。"""
|
|
209
|
-
if self._finder:
|
|
210
|
-
hit = self._finder(name)
|
|
211
|
-
if hit:
|
|
212
|
-
return hit
|
|
213
|
-
return self.images_dir / name
|
|
214
|
-
|
|
215
|
-
def list(self, limit: int = 24, offset: int = 0, q: str = "", status: str = "") -> dict:
|
|
216
|
-
q = (q or "").strip().lower()
|
|
217
|
-
status = (status or "").strip()
|
|
218
|
-
with self._lock:
|
|
219
|
-
rows = list(reversed(self._items))
|
|
220
|
-
if status:
|
|
221
|
-
rows = [r for r in rows if r.get("status") == status]
|
|
222
|
-
if q:
|
|
223
|
-
rows = [r for r in rows if q in " ".join(
|
|
224
|
-
str(r.get(k) or "") for k in ("title", "topic", "group", "account", "prompt")
|
|
225
|
-
).lower()]
|
|
226
|
-
total = len(rows)
|
|
227
|
-
limit = max(1, min(int(limit or 24), 100))
|
|
228
|
-
offset = max(0, int(offset or 0))
|
|
229
|
-
page = rows[offset:offset + limit]
|
|
230
|
-
return {
|
|
231
|
-
"total": total,
|
|
232
|
-
"offset": offset,
|
|
233
|
-
"limit": limit,
|
|
234
|
-
"has_more": offset + len(page) < total,
|
|
235
|
-
"items": [self._public(r) for r in page],
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
# ---------- 按任务分组 ----------
|
|
239
|
-
def _task_brief(self, r: dict) -> dict:
|
|
240
|
-
"""任务里的单张记录:去掉提示词全文,列表才不会一次传几百 KB。"""
|
|
241
|
-
img = r.get("image")
|
|
242
|
-
return {
|
|
243
|
-
"id": r["id"],
|
|
244
|
-
"title": r.get("title") or r.get("topic") or "",
|
|
245
|
-
"status": r.get("status") or "done",
|
|
246
|
-
"mode": r.get("mode") or "image",
|
|
247
|
-
"account": r.get("account") or "",
|
|
248
|
-
"finished": r.get("finished"),
|
|
249
|
-
"width": r.get("width"),
|
|
250
|
-
"height": r.get("height"),
|
|
251
|
-
"bytes": r.get("bytes"),
|
|
252
|
-
"error": (r.get("error") or "")[:200],
|
|
253
|
-
"body": (r.get("body") or "")[:160],
|
|
254
|
-
"has_prompt": bool((r.get("prompt") or "").strip()),
|
|
255
|
-
"image_url": f"/files/{img}" if img else None,
|
|
256
|
-
"thumb_url": f"/api/history/{r['id']}/thumb" if img else None,
|
|
257
|
-
"download_url": f"/api/history/{r['id']}/download" if img else None,
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
@staticmethod
|
|
261
|
-
def _group_rows(rows: list[dict]) -> tuple[list[str], dict[str, dict]]:
|
|
262
|
-
"""把行聚成任务。列表、载入、删除都必须用这同一套口径,否则删错行。"""
|
|
263
|
-
groups: dict[str, dict] = {}
|
|
264
|
-
order: list[str] = []
|
|
265
|
-
prev_key: str | None = None
|
|
266
|
-
prev_g = ""
|
|
267
|
-
prev_ts = 0
|
|
268
|
-
|
|
269
|
-
for r in rows:
|
|
270
|
-
tid = str(r.get("task_id") or "")
|
|
271
|
-
g = str(r.get("group") or "").strip()
|
|
272
|
-
ts = int(r.get("created") or 0)
|
|
273
|
-
if tid:
|
|
274
|
-
key = "t:" + tid
|
|
275
|
-
prev_key = None
|
|
276
|
-
else:
|
|
277
|
-
merged = bool(prev_key and g and g == prev_g and ts and prev_ts
|
|
278
|
-
and 0 <= ts - prev_ts <= 300)
|
|
279
|
-
if not merged:
|
|
280
|
-
prev_key = ("s:" + r["id"]) if g else None
|
|
281
|
-
key = prev_key or ("s:" + r["id"])
|
|
282
|
-
prev_g = g
|
|
283
|
-
prev_ts = ts
|
|
284
|
-
|
|
285
|
-
t = groups.get(key)
|
|
286
|
-
if t is None:
|
|
287
|
-
t = {
|
|
288
|
-
"task_id": key.split(":", 1)[1],
|
|
289
|
-
"title": g or r.get("title") or r.get("topic") or "未命名",
|
|
290
|
-
"group": g,
|
|
291
|
-
"created": ts or int(r.get("finished") or 0),
|
|
292
|
-
"finished": 0,
|
|
293
|
-
"total": 0,
|
|
294
|
-
"done": 0,
|
|
295
|
-
"error": 0,
|
|
296
|
-
"modes": {},
|
|
297
|
-
"items": [],
|
|
298
|
-
}
|
|
299
|
-
groups[key] = t
|
|
300
|
-
order.append(key)
|
|
301
|
-
t["total"] += 1
|
|
302
|
-
fin = int(r.get("finished") or 0)
|
|
303
|
-
t["finished"] = max(t["finished"], fin)
|
|
304
|
-
if r.get("status") == "done":
|
|
305
|
-
t["done"] += 1
|
|
306
|
-
elif r.get("status") == "error":
|
|
307
|
-
t["error"] += 1
|
|
308
|
-
m = r.get("mode") or "image"
|
|
309
|
-
t["modes"][m] = t["modes"].get(m, 0) + 1
|
|
310
|
-
t["items"].append(r)
|
|
311
|
-
|
|
312
|
-
# 组内按提交顺序(01 02 03 的顺序感)
|
|
313
|
-
for t in groups.values():
|
|
314
|
-
t["items"] = _order_items(t["items"])
|
|
315
|
-
return order, groups
|
|
316
|
-
|
|
317
|
-
def _task_rows(self, task_id: str) -> list[dict]:
|
|
318
|
-
"""按任务号取整组行。老记录的合成任务号就是组内第一条记录的 id。"""
|
|
319
|
-
with self._lock:
|
|
320
|
-
rows = list(self._items)
|
|
321
|
-
_, groups = self._group_rows(rows)
|
|
322
|
-
g = groups.get("t:" + task_id) or groups.get("s:" + task_id)
|
|
323
|
-
if g:
|
|
324
|
-
return g["items"]
|
|
325
|
-
return [r for r in rows if str(r.get("id")) == task_id]
|
|
326
|
-
|
|
327
|
-
def task_detail(self, task_id: str) -> dict | None:
|
|
328
|
-
"""载入历史任务用:带提示词全文,外加当时选的图型/风格/主题色/比例。"""
|
|
329
|
-
rows = self._task_rows(task_id)
|
|
330
|
-
if not rows:
|
|
331
|
-
return None
|
|
332
|
-
first = rows[0]
|
|
333
|
-
|
|
334
|
-
def pick(k: str):
|
|
335
|
-
for r in rows:
|
|
336
|
-
v = r.get(k)
|
|
337
|
-
if v not in (None, ""):
|
|
338
|
-
return v
|
|
339
|
-
return None
|
|
340
|
-
|
|
341
|
-
items = [{
|
|
342
|
-
"id": r["id"],
|
|
343
|
-
"title": r.get("title") or r.get("topic") or "",
|
|
344
|
-
"body": r.get("body") or "",
|
|
345
|
-
"prompt": r.get("prompt") or "",
|
|
346
|
-
"status": r.get("status") or "done",
|
|
347
|
-
"mode": r.get("mode") or "image",
|
|
348
|
-
"image_url": f"/files/{r['image']}" if r.get("image") else None,
|
|
349
|
-
} for r in rows]
|
|
350
|
-
return {
|
|
351
|
-
"task_id": task_id,
|
|
352
|
-
"title": first.get("group") or first.get("title") or first.get("topic") or "未命名任务",
|
|
353
|
-
"group": first.get("group") or "",
|
|
354
|
-
"topic": first.get("topic") or "",
|
|
355
|
-
# 整篇正文:同一任务内每条都一样,取最长的那条(防止个别记录没写进去)
|
|
356
|
-
"doc_body": max((r.get("doc_body") or "" for r in rows), key=len),
|
|
357
|
-
"created": first.get("created"),
|
|
358
|
-
"style_id": pick("style_id"),
|
|
359
|
-
"layout_id": pick("layout_id"),
|
|
360
|
-
"color_id": pick("color_id"),
|
|
361
|
-
"ratio": pick("ratio"),
|
|
362
|
-
"ref_id": pick("ref_id"),
|
|
363
|
-
"image_mode": pick("image_mode") or "graphic-text",
|
|
364
|
-
"shoot_type": pick("shoot_type") or "",
|
|
365
|
-
"items": items,
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
def bundle_data(self, task_id: str) -> dict | None:
|
|
369
|
-
"""打包下载用:整组行(已按提交顺序排好)+ 任务标题 + 整篇正文。"""
|
|
370
|
-
rows = self._task_rows(task_id)
|
|
371
|
-
if not rows:
|
|
372
|
-
return None
|
|
373
|
-
first = rows[0]
|
|
374
|
-
return {
|
|
375
|
-
"title": first.get("group") or first.get("title") or first.get("topic") or "未命名任务",
|
|
376
|
-
"doc_body": max((r.get("doc_body") or "" for r in rows), key=len),
|
|
377
|
-
"rows": rows,
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
def _rewrite(self, rows: list[dict]) -> None:
|
|
381
|
-
"""整文件重写:JSONL 删行只能这么干。先写 tmp 再 rename,中途断电也不会剩半截。"""
|
|
382
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
-
tmp = self.path.with_suffix(".jsonl.tmp")
|
|
384
|
-
with tmp.open("w", encoding="utf-8") as f:
|
|
385
|
-
for r in rows:
|
|
386
|
-
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
387
|
-
tmp.replace(self.path)
|
|
388
|
-
|
|
389
|
-
def drop_record(self, job_id: str, task_id: str | None = None) -> dict:
|
|
390
|
-
"""单张重做:删掉一条旧记录(索引 + 原图 + 缩略图)。
|
|
391
|
-
|
|
392
|
-
task_id 给了就要求旧记录属于同一个任务,防止前端传错 id 删到别处。
|
|
393
|
-
只在新图成功后调用;失败时旧图必须原样留着。
|
|
394
|
-
"""
|
|
395
|
-
jid = str(job_id or "")
|
|
396
|
-
if not jid:
|
|
397
|
-
return {"ok": False, "deleted": 0, "images": 0}
|
|
398
|
-
with self._lock:
|
|
399
|
-
hit = next((r for r in self._items if str(r.get("id")) == jid), None)
|
|
400
|
-
if not hit:
|
|
401
|
-
return {"ok": False, "deleted": 0, "images": 0}
|
|
402
|
-
if task_id and str(hit.get("task_id") or "") != str(task_id):
|
|
403
|
-
return {"ok": False, "deleted": 0, "images": 0}
|
|
404
|
-
keep = [r for r in self._items if str(r.get("id")) != jid]
|
|
405
|
-
self._items = keep
|
|
406
|
-
self._ids.discard(jid)
|
|
407
|
-
self._rewrite(keep)
|
|
408
|
-
thumbs = self.thumbs_dir
|
|
409
|
-
gone = 0
|
|
410
|
-
paths = ((self._any_image(str(hit["image"]))) if hit.get("image") else None,
|
|
411
|
-
thumbs / (jid + ".jpg"),
|
|
412
|
-
thumbs / (jid + "_c.webp"), thumbs / (jid + "_p.webp"))
|
|
413
|
-
for p in paths:
|
|
414
|
-
if p is None:
|
|
415
|
-
continue
|
|
416
|
-
try:
|
|
417
|
-
if p.is_file():
|
|
418
|
-
p.unlink()
|
|
419
|
-
gone += 1
|
|
420
|
-
except Exception:
|
|
421
|
-
pass
|
|
422
|
-
return {"ok": True, "deleted": 1, "images": gone}
|
|
423
|
-
|
|
424
|
-
def delete_task(self, task_id: str) -> dict:
|
|
425
|
-
"""删掉整个任务:索引、图片原图、缩略图一起清。
|
|
426
|
-
|
|
427
|
-
图片文件名带 job id,不存在两条记录共用一张图,所以可以直接删文件。
|
|
428
|
-
"""
|
|
429
|
-
rows = self._task_rows(task_id)
|
|
430
|
-
if not rows:
|
|
431
|
-
return {"ok": False, "deleted": 0, "images": 0}
|
|
432
|
-
ids = {str(r["id"]) for r in rows}
|
|
433
|
-
with self._lock:
|
|
434
|
-
dropped = [r for r in self._items if str(r["id"]) in ids]
|
|
435
|
-
keep = [r for r in self._items if str(r["id"]) not in ids]
|
|
436
|
-
self._items = keep
|
|
437
|
-
self._ids -= ids
|
|
438
|
-
self._rewrite(keep)
|
|
439
|
-
# 文件删除放在锁外:磁盘慢的时候别把出图线程堵住
|
|
440
|
-
thumbs = self.thumbs_dir
|
|
441
|
-
gone = 0
|
|
442
|
-
for r in dropped:
|
|
443
|
-
for p in ((self._any_image(str(r["image"]))) if r.get("image") else None,
|
|
444
|
-
thumbs / (str(r["id"]) + ".jpg"),
|
|
445
|
-
thumbs / (str(r["id"]) + "_c.webp"),
|
|
446
|
-
thumbs / (str(r["id"]) + "_p.webp")):
|
|
447
|
-
if p is None:
|
|
448
|
-
continue
|
|
449
|
-
try:
|
|
450
|
-
if p.is_file():
|
|
451
|
-
p.unlink()
|
|
452
|
-
gone += 1
|
|
453
|
-
except Exception:
|
|
454
|
-
pass
|
|
455
|
-
return {"ok": True, "deleted": len(dropped), "images": gone}
|
|
456
|
-
|
|
457
|
-
def tasks(self, limit: int = 20, offset: int = 0, q: str = "") -> dict:
|
|
458
|
-
"""按 task 聚合。老记录没有 task_id,用「同 group + 5 分钟内」近似归并。"""
|
|
459
|
-
q = (q or "").strip().lower()
|
|
460
|
-
with self._lock:
|
|
461
|
-
rows = list(self._items) # 写入顺序 ≈ 时间顺序
|
|
462
|
-
|
|
463
|
-
order, groups = self._group_rows(rows)
|
|
464
|
-
tasks = [groups[k] for k in order]
|
|
465
|
-
tasks.sort(key=lambda t: (t["finished"] or t["created"], t["task_id"]), reverse=True)
|
|
466
|
-
|
|
467
|
-
if q:
|
|
468
|
-
def hit(t: dict) -> bool:
|
|
469
|
-
blob = " ".join([str(t.get("title") or ""), str(t.get("group") or "")] +
|
|
470
|
-
[str(i.get("title") or "") for i in t["items"]])
|
|
471
|
-
return q in blob.lower()
|
|
472
|
-
tasks = [t for t in tasks if hit(t)]
|
|
473
|
-
|
|
474
|
-
total = len(tasks)
|
|
475
|
-
limit = max(1, min(int(limit or 20), 100))
|
|
476
|
-
offset = max(0, int(offset or 0))
|
|
477
|
-
page = tasks[offset:offset + limit]
|
|
478
|
-
out = []
|
|
479
|
-
for t in page:
|
|
480
|
-
d = {
|
|
481
|
-
"task_id": t["task_id"],
|
|
482
|
-
"title": t["title"],
|
|
483
|
-
"group": t["group"],
|
|
484
|
-
"created": t["created"],
|
|
485
|
-
"finished": t["finished"],
|
|
486
|
-
"total": t["total"],
|
|
487
|
-
"done": t["done"],
|
|
488
|
-
"error": t["error"],
|
|
489
|
-
"modes": t["modes"],
|
|
490
|
-
"items": [self._task_brief(i) for i in t["items"]],
|
|
491
|
-
}
|
|
492
|
-
out.append(d)
|
|
493
|
-
return {
|
|
494
|
-
"total": total,
|
|
495
|
-
"offset": offset,
|
|
496
|
-
"limit": limit,
|
|
497
|
-
"has_more": offset + len(page) < total,
|
|
498
|
-
"page": offset // limit + 1,
|
|
499
|
-
"pages": max(1, (total + limit - 1) // limit),
|
|
500
|
-
"items": out,
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
def _public(self, r: dict) -> dict:
|
|
504
|
-
d = dict(r)
|
|
505
|
-
img = r.get("image")
|
|
506
|
-
d["raw_url"] = f"/files/{img}" if img else None
|
|
507
|
-
# 公众号首图 2.35:1 是裁出来的成品:列表大图、下载都给裁好的那张,
|
|
508
|
-
# 未裁原图留在 raw_url,需要回头看完整构图时还能拿
|
|
509
|
-
d["image_url"] = (f"/api/history/{r['id']}/final"
|
|
510
|
-
if img and imaging.needs_crop(r.get("ratio")) else d["raw_url"])
|
|
511
|
-
d["thumb_url"] = f"/api/history/{r['id']}/thumb" if img else None
|
|
512
|
-
d["download_url"] = f"/api/history/{r['id']}/download" if img else None
|
|
513
|
-
return d
|
|
514
|
-
|
|
515
|
-
def stats(self) -> dict:
|
|
516
|
-
today = time.strftime("%Y-%m-%d")
|
|
517
|
-
with self._lock:
|
|
518
|
-
rows = list(self._items)
|
|
519
|
-
done = sum(1 for r in rows if r.get("status") == "done")
|
|
520
|
-
err = sum(1 for r in rows if r.get("status") == "error")
|
|
521
|
-
t_done = t_err = 0
|
|
522
|
-
for r in rows:
|
|
523
|
-
if time.strftime("%Y-%m-%d", time.localtime(int(r.get("finished") or 0))) != today:
|
|
524
|
-
continue
|
|
525
|
-
if r.get("status") == "done":
|
|
526
|
-
t_done += 1
|
|
527
|
-
elif r.get("status") == "error":
|
|
528
|
-
t_err += 1
|
|
529
|
-
return {
|
|
530
|
-
"total": len(rows),
|
|
531
|
-
"done": done,
|
|
532
|
-
"error": err,
|
|
533
|
-
"today_done": t_done,
|
|
534
|
-
"today_error": t_err,
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
def account_stats(self) -> tuple[dict, dict]:
|
|
538
|
-
"""(按 env_id, 按掩码)两种账号统计。
|
|
539
|
-
|
|
540
|
-
掩码只留前两位,Hou*** 这种会撞;新记录都带 env_id,按 env 匹配才准。
|
|
541
|
-
没有 env_id 的老记录退回按掩码匹配。
|
|
542
|
-
"""
|
|
543
|
-
with self._lock:
|
|
544
|
-
rows = list(self._items)
|
|
545
|
-
by_env: dict = {}
|
|
546
|
-
by_mask: dict = {}
|
|
547
|
-
for r in rows:
|
|
548
|
-
ok = r.get("status") == "done"
|
|
549
|
-
fin = int(r.get("finished") or 0)
|
|
550
|
-
env = r.get("env_id")
|
|
551
|
-
if env not in (None, ""):
|
|
552
|
-
d = by_env.setdefault(str(env), {"done": 0, "error": 0, "last_ok": 0})
|
|
553
|
-
if ok:
|
|
554
|
-
d["done"] += 1
|
|
555
|
-
d["last_ok"] = max(d["last_ok"], fin)
|
|
556
|
-
else:
|
|
557
|
-
d["error"] += 1
|
|
558
|
-
else:
|
|
559
|
-
k = str(r.get("account") or "")
|
|
560
|
-
if not k:
|
|
561
|
-
continue
|
|
562
|
-
d = by_mask.setdefault(k, {"done": 0, "error": 0, "last_ok": 0})
|
|
563
|
-
if ok:
|
|
564
|
-
d["done"] += 1
|
|
565
|
-
d["last_ok"] = max(d["last_ok"], fin)
|
|
566
|
-
else:
|
|
567
|
-
d["error"] += 1
|
|
568
|
-
return by_env, by_mask
|
|
569
|
-
|
|
570
|
-
def recent_errors(self, n: int = 6) -> list[dict]:
|
|
571
|
-
with self._lock:
|
|
572
|
-
rows = [r for r in reversed(self._items) if r.get("status") == "error"]
|
|
573
|
-
return [{"id": r["id"], "title": r.get("title") or r.get("topic"),
|
|
574
|
-
"error": r.get("error"), "finished": r.get("finished"),
|
|
575
|
-
"account": r.get("account")} for r in rows[:n]]
|
|
1
|
+
"""生成历史:一条任务一行 JSONL,落盘后可跨重启查询。
|
|
2
|
+
|
|
3
|
+
图片本身已经在 workbench/images/{job_id}.png,这里只存索引与元信息,
|
|
4
|
+
所以历史列表是纯读文件,不会因为图片多而变慢。
|
|
5
|
+
|
|
6
|
+
两层结构:
|
|
7
|
+
- 一行 = 一次出图(job),是历史的原子单位;
|
|
8
|
+
- 一次点击「批量出图」会提交 N 个 job,它们共用同一个 task_id。
|
|
9
|
+
前端历史面板按 task 分组展示,默认折叠,展开才看这一批的图。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import imaging
|
|
21
|
+
|
|
22
|
+
MAX_SCAN = 4000 # 最多回读多少行,防止文件无限增长后启动变慢
|
|
23
|
+
|
|
24
|
+
# 标题开头的序号:「03 24小时冷冻」→ 3
|
|
25
|
+
_TITLE_NO = re.compile(r"^\s*0*(\d{1,3})(?=[\s、..::\-—_|/))]|$)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _title_no(s: str) -> int | None:
|
|
29
|
+
m = _TITLE_NO.match(s or "")
|
|
30
|
+
return int(m.group(1)) if m else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _order_items(items: list[dict]) -> list[dict]:
|
|
34
|
+
"""组内排序:seq 优先,其次标题里的序号,最后才按时间。
|
|
35
|
+
|
|
36
|
+
created 只有秒级精度,一次批量出图 8 条基本全落在同一两秒里,只按时间
|
|
37
|
+
排会退化成按随机 id 排,载入历史后顺序就成了 03 01 02 06 04 08 05 07。
|
|
38
|
+
"""
|
|
39
|
+
if not items:
|
|
40
|
+
return items
|
|
41
|
+
if all(isinstance(x.get("seq"), int) for x in items):
|
|
42
|
+
return sorted(items, key=lambda x: (x["seq"], int(x.get("created") or 0)))
|
|
43
|
+
nums = [_title_no(x.get("title") or x.get("topic") or "") for x in items]
|
|
44
|
+
if all(v is not None for v in nums) and len(set(nums)) == len(nums):
|
|
45
|
+
return [x for _, x in sorted(zip(nums, items), key=lambda p: p[0])]
|
|
46
|
+
return sorted(items, key=lambda x: (int(x.get("created") or 0), str(x["id"])))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_DROP = object() # 「这个值没法写进 JSON,丢掉」的哨兵,和合法的 None 区分开
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clean(v):
|
|
53
|
+
"""递归洗成纯 JSON 基础类型:保留 list/dict(失败轨迹就是这么存的),丢掉异常对象。"""
|
|
54
|
+
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
55
|
+
return v
|
|
56
|
+
if isinstance(v, dict):
|
|
57
|
+
out = {}
|
|
58
|
+
for k, x in v.items():
|
|
59
|
+
c = _clean(x)
|
|
60
|
+
if c is not _DROP:
|
|
61
|
+
out[str(k)] = c
|
|
62
|
+
return out
|
|
63
|
+
if isinstance(v, (list, tuple)):
|
|
64
|
+
out = []
|
|
65
|
+
for x in v:
|
|
66
|
+
c = _clean(x)
|
|
67
|
+
if c is not _DROP:
|
|
68
|
+
out.append(c)
|
|
69
|
+
return out
|
|
70
|
+
return _DROP
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _safe(d: dict) -> dict:
|
|
74
|
+
"""只保留可 JSON 序列化的内容,避免 job 里混进 Task / 异常对象。
|
|
75
|
+
|
|
76
|
+
以前只认 str/int/float/bool/None,顺手把 list/dict 也丢了,
|
|
77
|
+
导致 attempts(每次失败用的哪个号)压根存不进历史。
|
|
78
|
+
"""
|
|
79
|
+
out = {}
|
|
80
|
+
for k, v in d.items():
|
|
81
|
+
c = _clean(v)
|
|
82
|
+
if c is not _DROP:
|
|
83
|
+
out[k] = c
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class History:
|
|
88
|
+
def __init__(self, path: Path, images_dir, thumbs_dir: Path | None = None,
|
|
89
|
+
finder=None) -> None:
|
|
90
|
+
self.path = path
|
|
91
|
+
# images_dir 可以是 Path,也可以是返回 Path 的函数:
|
|
92
|
+
# 用户在工作台里改了「生成图片保存目录」后,这里要立刻跟着走,不能等重启。
|
|
93
|
+
self._images = images_dir
|
|
94
|
+
# finder(name) 负责多目录兜底(换过保存目录后老图仍可读)
|
|
95
|
+
self._finder = finder
|
|
96
|
+
self.thumbs_dir = Path(thumbs_dir) if thumbs_dir else (
|
|
97
|
+
self.images_dir.parent / "thumbs")
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
self._items: list[dict] = []
|
|
100
|
+
self._ids: set[str] = set()
|
|
101
|
+
self._load()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def images_dir(self) -> Path:
|
|
105
|
+
d = self._images
|
|
106
|
+
return Path(d() if callable(d) else d)
|
|
107
|
+
|
|
108
|
+
# ---------- load / write ----------
|
|
109
|
+
def _load(self) -> None:
|
|
110
|
+
if not self.path.exists():
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
lines = self.path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
114
|
+
except Exception:
|
|
115
|
+
return
|
|
116
|
+
rows: list[dict] = []
|
|
117
|
+
for ln in lines[-MAX_SCAN:]:
|
|
118
|
+
ln = ln.strip()
|
|
119
|
+
if not ln:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
d = json.loads(ln)
|
|
123
|
+
except Exception:
|
|
124
|
+
continue
|
|
125
|
+
if isinstance(d, dict) and d.get("id"):
|
|
126
|
+
rows.append(d)
|
|
127
|
+
self._items = rows
|
|
128
|
+
self._ids = {r["id"] for r in rows}
|
|
129
|
+
|
|
130
|
+
def _append(self, rec: dict) -> None:
|
|
131
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
line = json.dumps(rec, ensure_ascii=False)
|
|
133
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
134
|
+
f.write(line + "\n")
|
|
135
|
+
|
|
136
|
+
# ---------- write ----------
|
|
137
|
+
def record(self, job: dict) -> None:
|
|
138
|
+
"""任务结束时调用一次。同一个 id 只记一次,重试不产生两条。"""
|
|
139
|
+
jid = str(job.get("id") or "")
|
|
140
|
+
if not jid:
|
|
141
|
+
return
|
|
142
|
+
result = job.get("result") or {}
|
|
143
|
+
rec = _safe({
|
|
144
|
+
"id": jid,
|
|
145
|
+
"task_id": job.get("task_id") or "",
|
|
146
|
+
"created": job.get("created") or int(time.time()),
|
|
147
|
+
"finished": job.get("finished") or int(time.time()),
|
|
148
|
+
"mode": job.get("mode") or "image",
|
|
149
|
+
"status": job.get("status") or "done",
|
|
150
|
+
"topic": job.get("topic") or "",
|
|
151
|
+
"title": job.get("title") or job.get("topic") or "",
|
|
152
|
+
"body": job.get("body") or "",
|
|
153
|
+
# 整篇正文(左侧「正文」框),载入历史时还原用;老记录没有这个字段
|
|
154
|
+
"doc_body": job.get("doc_body") or "",
|
|
155
|
+
# 这一批里的提交序号,0 起。并行出图完成顺序是乱的,靠它还原清单顺序
|
|
156
|
+
"seq": job.get("seq"),
|
|
157
|
+
"group": job.get("group") or "",
|
|
158
|
+
"account": job.get("account") or "",
|
|
159
|
+
"env_id": job.get("env_id"),
|
|
160
|
+
"style_id": job.get("style_id"),
|
|
161
|
+
"layout_id": job.get("layout_id"),
|
|
162
|
+
"color_id": job.get("color_id"),
|
|
163
|
+
"ratio": job.get("ratio"),
|
|
164
|
+
"ref_id": job.get("ref_id") or "",
|
|
165
|
+
# 出图模式(图文 / 摄影)与拍摄类型,载入历史时一起还原
|
|
166
|
+
"image_mode": job.get("image_mode") or "graphic-text",
|
|
167
|
+
"shoot_type": job.get("shoot_type") or "",
|
|
168
|
+
"prompt": job.get("prompt") or "",
|
|
169
|
+
# 失败/换号的完整轨迹:第几次、哪个号、什么错。成功那次也在里面,
|
|
170
|
+
# 前端据此显示「换 2 个号后成功」或「换了 3 个号仍未成功」
|
|
171
|
+
"attempts": job.get("attempts") or [],
|
|
172
|
+
"account_email": job.get("account_email") or "",
|
|
173
|
+
"image": result.get("image"),
|
|
174
|
+
"width": result.get("width"),
|
|
175
|
+
"height": result.get("height"),
|
|
176
|
+
"bytes": result.get("bytes"),
|
|
177
|
+
"error": job.get("error") or "",
|
|
178
|
+
})
|
|
179
|
+
with self._lock:
|
|
180
|
+
if jid in self._ids:
|
|
181
|
+
return
|
|
182
|
+
self._ids.add(jid)
|
|
183
|
+
self._items.append(rec)
|
|
184
|
+
try:
|
|
185
|
+
self._append(rec)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
# ---------- read ----------
|
|
190
|
+
def get(self, job_id: str) -> dict | None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
return next((r for r in reversed(self._items) if r["id"] == job_id), None)
|
|
193
|
+
|
|
194
|
+
def image_path(self, job_id: str) -> Path | None:
|
|
195
|
+
rec = self.get(job_id)
|
|
196
|
+
if not rec or not rec.get("image"):
|
|
197
|
+
return None
|
|
198
|
+
name = str(rec["image"])
|
|
199
|
+
if self._finder:
|
|
200
|
+
return self._finder(name)
|
|
201
|
+
for d in (self.images_dir, self.images_dir.parent / "images"):
|
|
202
|
+
p = d / name
|
|
203
|
+
if p.is_file():
|
|
204
|
+
return p
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _any_image(self, name: str) -> Path:
|
|
208
|
+
"""按文件名定位原图:交给 finder 做多目录兜底,没注入就按当前目录找。"""
|
|
209
|
+
if self._finder:
|
|
210
|
+
hit = self._finder(name)
|
|
211
|
+
if hit:
|
|
212
|
+
return hit
|
|
213
|
+
return self.images_dir / name
|
|
214
|
+
|
|
215
|
+
def list(self, limit: int = 24, offset: int = 0, q: str = "", status: str = "") -> dict:
|
|
216
|
+
q = (q or "").strip().lower()
|
|
217
|
+
status = (status or "").strip()
|
|
218
|
+
with self._lock:
|
|
219
|
+
rows = list(reversed(self._items))
|
|
220
|
+
if status:
|
|
221
|
+
rows = [r for r in rows if r.get("status") == status]
|
|
222
|
+
if q:
|
|
223
|
+
rows = [r for r in rows if q in " ".join(
|
|
224
|
+
str(r.get(k) or "") for k in ("title", "topic", "group", "account", "prompt")
|
|
225
|
+
).lower()]
|
|
226
|
+
total = len(rows)
|
|
227
|
+
limit = max(1, min(int(limit or 24), 100))
|
|
228
|
+
offset = max(0, int(offset or 0))
|
|
229
|
+
page = rows[offset:offset + limit]
|
|
230
|
+
return {
|
|
231
|
+
"total": total,
|
|
232
|
+
"offset": offset,
|
|
233
|
+
"limit": limit,
|
|
234
|
+
"has_more": offset + len(page) < total,
|
|
235
|
+
"items": [self._public(r) for r in page],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# ---------- 按任务分组 ----------
|
|
239
|
+
def _task_brief(self, r: dict) -> dict:
|
|
240
|
+
"""任务里的单张记录:去掉提示词全文,列表才不会一次传几百 KB。"""
|
|
241
|
+
img = r.get("image")
|
|
242
|
+
return {
|
|
243
|
+
"id": r["id"],
|
|
244
|
+
"title": r.get("title") or r.get("topic") or "",
|
|
245
|
+
"status": r.get("status") or "done",
|
|
246
|
+
"mode": r.get("mode") or "image",
|
|
247
|
+
"account": r.get("account") or "",
|
|
248
|
+
"finished": r.get("finished"),
|
|
249
|
+
"width": r.get("width"),
|
|
250
|
+
"height": r.get("height"),
|
|
251
|
+
"bytes": r.get("bytes"),
|
|
252
|
+
"error": (r.get("error") or "")[:200],
|
|
253
|
+
"body": (r.get("body") or "")[:160],
|
|
254
|
+
"has_prompt": bool((r.get("prompt") or "").strip()),
|
|
255
|
+
"image_url": f"/files/{img}" if img else None,
|
|
256
|
+
"thumb_url": f"/api/history/{r['id']}/thumb" if img else None,
|
|
257
|
+
"download_url": f"/api/history/{r['id']}/download" if img else None,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _group_rows(rows: list[dict]) -> tuple[list[str], dict[str, dict]]:
|
|
262
|
+
"""把行聚成任务。列表、载入、删除都必须用这同一套口径,否则删错行。"""
|
|
263
|
+
groups: dict[str, dict] = {}
|
|
264
|
+
order: list[str] = []
|
|
265
|
+
prev_key: str | None = None
|
|
266
|
+
prev_g = ""
|
|
267
|
+
prev_ts = 0
|
|
268
|
+
|
|
269
|
+
for r in rows:
|
|
270
|
+
tid = str(r.get("task_id") or "")
|
|
271
|
+
g = str(r.get("group") or "").strip()
|
|
272
|
+
ts = int(r.get("created") or 0)
|
|
273
|
+
if tid:
|
|
274
|
+
key = "t:" + tid
|
|
275
|
+
prev_key = None
|
|
276
|
+
else:
|
|
277
|
+
merged = bool(prev_key and g and g == prev_g and ts and prev_ts
|
|
278
|
+
and 0 <= ts - prev_ts <= 300)
|
|
279
|
+
if not merged:
|
|
280
|
+
prev_key = ("s:" + r["id"]) if g else None
|
|
281
|
+
key = prev_key or ("s:" + r["id"])
|
|
282
|
+
prev_g = g
|
|
283
|
+
prev_ts = ts
|
|
284
|
+
|
|
285
|
+
t = groups.get(key)
|
|
286
|
+
if t is None:
|
|
287
|
+
t = {
|
|
288
|
+
"task_id": key.split(":", 1)[1],
|
|
289
|
+
"title": g or r.get("title") or r.get("topic") or "未命名",
|
|
290
|
+
"group": g,
|
|
291
|
+
"created": ts or int(r.get("finished") or 0),
|
|
292
|
+
"finished": 0,
|
|
293
|
+
"total": 0,
|
|
294
|
+
"done": 0,
|
|
295
|
+
"error": 0,
|
|
296
|
+
"modes": {},
|
|
297
|
+
"items": [],
|
|
298
|
+
}
|
|
299
|
+
groups[key] = t
|
|
300
|
+
order.append(key)
|
|
301
|
+
t["total"] += 1
|
|
302
|
+
fin = int(r.get("finished") or 0)
|
|
303
|
+
t["finished"] = max(t["finished"], fin)
|
|
304
|
+
if r.get("status") == "done":
|
|
305
|
+
t["done"] += 1
|
|
306
|
+
elif r.get("status") == "error":
|
|
307
|
+
t["error"] += 1
|
|
308
|
+
m = r.get("mode") or "image"
|
|
309
|
+
t["modes"][m] = t["modes"].get(m, 0) + 1
|
|
310
|
+
t["items"].append(r)
|
|
311
|
+
|
|
312
|
+
# 组内按提交顺序(01 02 03 的顺序感)
|
|
313
|
+
for t in groups.values():
|
|
314
|
+
t["items"] = _order_items(t["items"])
|
|
315
|
+
return order, groups
|
|
316
|
+
|
|
317
|
+
def _task_rows(self, task_id: str) -> list[dict]:
|
|
318
|
+
"""按任务号取整组行。老记录的合成任务号就是组内第一条记录的 id。"""
|
|
319
|
+
with self._lock:
|
|
320
|
+
rows = list(self._items)
|
|
321
|
+
_, groups = self._group_rows(rows)
|
|
322
|
+
g = groups.get("t:" + task_id) or groups.get("s:" + task_id)
|
|
323
|
+
if g:
|
|
324
|
+
return g["items"]
|
|
325
|
+
return [r for r in rows if str(r.get("id")) == task_id]
|
|
326
|
+
|
|
327
|
+
def task_detail(self, task_id: str) -> dict | None:
|
|
328
|
+
"""载入历史任务用:带提示词全文,外加当时选的图型/风格/主题色/比例。"""
|
|
329
|
+
rows = self._task_rows(task_id)
|
|
330
|
+
if not rows:
|
|
331
|
+
return None
|
|
332
|
+
first = rows[0]
|
|
333
|
+
|
|
334
|
+
def pick(k: str):
|
|
335
|
+
for r in rows:
|
|
336
|
+
v = r.get(k)
|
|
337
|
+
if v not in (None, ""):
|
|
338
|
+
return v
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
items = [{
|
|
342
|
+
"id": r["id"],
|
|
343
|
+
"title": r.get("title") or r.get("topic") or "",
|
|
344
|
+
"body": r.get("body") or "",
|
|
345
|
+
"prompt": r.get("prompt") or "",
|
|
346
|
+
"status": r.get("status") or "done",
|
|
347
|
+
"mode": r.get("mode") or "image",
|
|
348
|
+
"image_url": f"/files/{r['image']}" if r.get("image") else None,
|
|
349
|
+
} for r in rows]
|
|
350
|
+
return {
|
|
351
|
+
"task_id": task_id,
|
|
352
|
+
"title": first.get("group") or first.get("title") or first.get("topic") or "未命名任务",
|
|
353
|
+
"group": first.get("group") or "",
|
|
354
|
+
"topic": first.get("topic") or "",
|
|
355
|
+
# 整篇正文:同一任务内每条都一样,取最长的那条(防止个别记录没写进去)
|
|
356
|
+
"doc_body": max((r.get("doc_body") or "" for r in rows), key=len),
|
|
357
|
+
"created": first.get("created"),
|
|
358
|
+
"style_id": pick("style_id"),
|
|
359
|
+
"layout_id": pick("layout_id"),
|
|
360
|
+
"color_id": pick("color_id"),
|
|
361
|
+
"ratio": pick("ratio"),
|
|
362
|
+
"ref_id": pick("ref_id"),
|
|
363
|
+
"image_mode": pick("image_mode") or "graphic-text",
|
|
364
|
+
"shoot_type": pick("shoot_type") or "",
|
|
365
|
+
"items": items,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
def bundle_data(self, task_id: str) -> dict | None:
|
|
369
|
+
"""打包下载用:整组行(已按提交顺序排好)+ 任务标题 + 整篇正文。"""
|
|
370
|
+
rows = self._task_rows(task_id)
|
|
371
|
+
if not rows:
|
|
372
|
+
return None
|
|
373
|
+
first = rows[0]
|
|
374
|
+
return {
|
|
375
|
+
"title": first.get("group") or first.get("title") or first.get("topic") or "未命名任务",
|
|
376
|
+
"doc_body": max((r.get("doc_body") or "" for r in rows), key=len),
|
|
377
|
+
"rows": rows,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
def _rewrite(self, rows: list[dict]) -> None:
|
|
381
|
+
"""整文件重写:JSONL 删行只能这么干。先写 tmp 再 rename,中途断电也不会剩半截。"""
|
|
382
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
tmp = self.path.with_suffix(".jsonl.tmp")
|
|
384
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
385
|
+
for r in rows:
|
|
386
|
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
387
|
+
tmp.replace(self.path)
|
|
388
|
+
|
|
389
|
+
def drop_record(self, job_id: str, task_id: str | None = None) -> dict:
|
|
390
|
+
"""单张重做:删掉一条旧记录(索引 + 原图 + 缩略图)。
|
|
391
|
+
|
|
392
|
+
task_id 给了就要求旧记录属于同一个任务,防止前端传错 id 删到别处。
|
|
393
|
+
只在新图成功后调用;失败时旧图必须原样留着。
|
|
394
|
+
"""
|
|
395
|
+
jid = str(job_id or "")
|
|
396
|
+
if not jid:
|
|
397
|
+
return {"ok": False, "deleted": 0, "images": 0}
|
|
398
|
+
with self._lock:
|
|
399
|
+
hit = next((r for r in self._items if str(r.get("id")) == jid), None)
|
|
400
|
+
if not hit:
|
|
401
|
+
return {"ok": False, "deleted": 0, "images": 0}
|
|
402
|
+
if task_id and str(hit.get("task_id") or "") != str(task_id):
|
|
403
|
+
return {"ok": False, "deleted": 0, "images": 0}
|
|
404
|
+
keep = [r for r in self._items if str(r.get("id")) != jid]
|
|
405
|
+
self._items = keep
|
|
406
|
+
self._ids.discard(jid)
|
|
407
|
+
self._rewrite(keep)
|
|
408
|
+
thumbs = self.thumbs_dir
|
|
409
|
+
gone = 0
|
|
410
|
+
paths = ((self._any_image(str(hit["image"]))) if hit.get("image") else None,
|
|
411
|
+
thumbs / (jid + ".jpg"),
|
|
412
|
+
thumbs / (jid + "_c.webp"), thumbs / (jid + "_p.webp"))
|
|
413
|
+
for p in paths:
|
|
414
|
+
if p is None:
|
|
415
|
+
continue
|
|
416
|
+
try:
|
|
417
|
+
if p.is_file():
|
|
418
|
+
p.unlink()
|
|
419
|
+
gone += 1
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
return {"ok": True, "deleted": 1, "images": gone}
|
|
423
|
+
|
|
424
|
+
def delete_task(self, task_id: str) -> dict:
|
|
425
|
+
"""删掉整个任务:索引、图片原图、缩略图一起清。
|
|
426
|
+
|
|
427
|
+
图片文件名带 job id,不存在两条记录共用一张图,所以可以直接删文件。
|
|
428
|
+
"""
|
|
429
|
+
rows = self._task_rows(task_id)
|
|
430
|
+
if not rows:
|
|
431
|
+
return {"ok": False, "deleted": 0, "images": 0}
|
|
432
|
+
ids = {str(r["id"]) for r in rows}
|
|
433
|
+
with self._lock:
|
|
434
|
+
dropped = [r for r in self._items if str(r["id"]) in ids]
|
|
435
|
+
keep = [r for r in self._items if str(r["id"]) not in ids]
|
|
436
|
+
self._items = keep
|
|
437
|
+
self._ids -= ids
|
|
438
|
+
self._rewrite(keep)
|
|
439
|
+
# 文件删除放在锁外:磁盘慢的时候别把出图线程堵住
|
|
440
|
+
thumbs = self.thumbs_dir
|
|
441
|
+
gone = 0
|
|
442
|
+
for r in dropped:
|
|
443
|
+
for p in ((self._any_image(str(r["image"]))) if r.get("image") else None,
|
|
444
|
+
thumbs / (str(r["id"]) + ".jpg"),
|
|
445
|
+
thumbs / (str(r["id"]) + "_c.webp"),
|
|
446
|
+
thumbs / (str(r["id"]) + "_p.webp")):
|
|
447
|
+
if p is None:
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
if p.is_file():
|
|
451
|
+
p.unlink()
|
|
452
|
+
gone += 1
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
return {"ok": True, "deleted": len(dropped), "images": gone}
|
|
456
|
+
|
|
457
|
+
def tasks(self, limit: int = 20, offset: int = 0, q: str = "") -> dict:
|
|
458
|
+
"""按 task 聚合。老记录没有 task_id,用「同 group + 5 分钟内」近似归并。"""
|
|
459
|
+
q = (q or "").strip().lower()
|
|
460
|
+
with self._lock:
|
|
461
|
+
rows = list(self._items) # 写入顺序 ≈ 时间顺序
|
|
462
|
+
|
|
463
|
+
order, groups = self._group_rows(rows)
|
|
464
|
+
tasks = [groups[k] for k in order]
|
|
465
|
+
tasks.sort(key=lambda t: (t["finished"] or t["created"], t["task_id"]), reverse=True)
|
|
466
|
+
|
|
467
|
+
if q:
|
|
468
|
+
def hit(t: dict) -> bool:
|
|
469
|
+
blob = " ".join([str(t.get("title") or ""), str(t.get("group") or "")] +
|
|
470
|
+
[str(i.get("title") or "") for i in t["items"]])
|
|
471
|
+
return q in blob.lower()
|
|
472
|
+
tasks = [t for t in tasks if hit(t)]
|
|
473
|
+
|
|
474
|
+
total = len(tasks)
|
|
475
|
+
limit = max(1, min(int(limit or 20), 100))
|
|
476
|
+
offset = max(0, int(offset or 0))
|
|
477
|
+
page = tasks[offset:offset + limit]
|
|
478
|
+
out = []
|
|
479
|
+
for t in page:
|
|
480
|
+
d = {
|
|
481
|
+
"task_id": t["task_id"],
|
|
482
|
+
"title": t["title"],
|
|
483
|
+
"group": t["group"],
|
|
484
|
+
"created": t["created"],
|
|
485
|
+
"finished": t["finished"],
|
|
486
|
+
"total": t["total"],
|
|
487
|
+
"done": t["done"],
|
|
488
|
+
"error": t["error"],
|
|
489
|
+
"modes": t["modes"],
|
|
490
|
+
"items": [self._task_brief(i) for i in t["items"]],
|
|
491
|
+
}
|
|
492
|
+
out.append(d)
|
|
493
|
+
return {
|
|
494
|
+
"total": total,
|
|
495
|
+
"offset": offset,
|
|
496
|
+
"limit": limit,
|
|
497
|
+
"has_more": offset + len(page) < total,
|
|
498
|
+
"page": offset // limit + 1,
|
|
499
|
+
"pages": max(1, (total + limit - 1) // limit),
|
|
500
|
+
"items": out,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
def _public(self, r: dict) -> dict:
|
|
504
|
+
d = dict(r)
|
|
505
|
+
img = r.get("image")
|
|
506
|
+
d["raw_url"] = f"/files/{img}" if img else None
|
|
507
|
+
# 公众号首图 2.35:1 是裁出来的成品:列表大图、下载都给裁好的那张,
|
|
508
|
+
# 未裁原图留在 raw_url,需要回头看完整构图时还能拿
|
|
509
|
+
d["image_url"] = (f"/api/history/{r['id']}/final"
|
|
510
|
+
if img and imaging.needs_crop(r.get("ratio")) else d["raw_url"])
|
|
511
|
+
d["thumb_url"] = f"/api/history/{r['id']}/thumb" if img else None
|
|
512
|
+
d["download_url"] = f"/api/history/{r['id']}/download" if img else None
|
|
513
|
+
return d
|
|
514
|
+
|
|
515
|
+
def stats(self) -> dict:
|
|
516
|
+
today = time.strftime("%Y-%m-%d")
|
|
517
|
+
with self._lock:
|
|
518
|
+
rows = list(self._items)
|
|
519
|
+
done = sum(1 for r in rows if r.get("status") == "done")
|
|
520
|
+
err = sum(1 for r in rows if r.get("status") == "error")
|
|
521
|
+
t_done = t_err = 0
|
|
522
|
+
for r in rows:
|
|
523
|
+
if time.strftime("%Y-%m-%d", time.localtime(int(r.get("finished") or 0))) != today:
|
|
524
|
+
continue
|
|
525
|
+
if r.get("status") == "done":
|
|
526
|
+
t_done += 1
|
|
527
|
+
elif r.get("status") == "error":
|
|
528
|
+
t_err += 1
|
|
529
|
+
return {
|
|
530
|
+
"total": len(rows),
|
|
531
|
+
"done": done,
|
|
532
|
+
"error": err,
|
|
533
|
+
"today_done": t_done,
|
|
534
|
+
"today_error": t_err,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
def account_stats(self) -> tuple[dict, dict]:
|
|
538
|
+
"""(按 env_id, 按掩码)两种账号统计。
|
|
539
|
+
|
|
540
|
+
掩码只留前两位,Hou*** 这种会撞;新记录都带 env_id,按 env 匹配才准。
|
|
541
|
+
没有 env_id 的老记录退回按掩码匹配。
|
|
542
|
+
"""
|
|
543
|
+
with self._lock:
|
|
544
|
+
rows = list(self._items)
|
|
545
|
+
by_env: dict = {}
|
|
546
|
+
by_mask: dict = {}
|
|
547
|
+
for r in rows:
|
|
548
|
+
ok = r.get("status") == "done"
|
|
549
|
+
fin = int(r.get("finished") or 0)
|
|
550
|
+
env = r.get("env_id")
|
|
551
|
+
if env not in (None, ""):
|
|
552
|
+
d = by_env.setdefault(str(env), {"done": 0, "error": 0, "last_ok": 0})
|
|
553
|
+
if ok:
|
|
554
|
+
d["done"] += 1
|
|
555
|
+
d["last_ok"] = max(d["last_ok"], fin)
|
|
556
|
+
else:
|
|
557
|
+
d["error"] += 1
|
|
558
|
+
else:
|
|
559
|
+
k = str(r.get("account") or "")
|
|
560
|
+
if not k:
|
|
561
|
+
continue
|
|
562
|
+
d = by_mask.setdefault(k, {"done": 0, "error": 0, "last_ok": 0})
|
|
563
|
+
if ok:
|
|
564
|
+
d["done"] += 1
|
|
565
|
+
d["last_ok"] = max(d["last_ok"], fin)
|
|
566
|
+
else:
|
|
567
|
+
d["error"] += 1
|
|
568
|
+
return by_env, by_mask
|
|
569
|
+
|
|
570
|
+
def recent_errors(self, n: int = 6) -> list[dict]:
|
|
571
|
+
with self._lock:
|
|
572
|
+
rows = [r for r in reversed(self._items) if r.get("status") == "error"]
|
|
573
|
+
return [{"id": r["id"], "title": r.get("title") or r.get("topic"),
|
|
574
|
+
"error": r.get("error"), "finished": r.get("finished"),
|
|
575
|
+
"account": r.get("account")} for r in rows[:n]]
|