dsh-plugin-image-workbench 1.1.5 → 1.1.7

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