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.
Files changed (172) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1329 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. 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]]