dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +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]]
|