dsh-plugin-image-workbench 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1329 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
|
@@ -1,550 +1,550 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import threading
|
|
5
|
-
import time
|
|
6
|
-
from collections.abc import Callable
|
|
7
|
-
from datetime import datetime
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from services.config import DATA_DIR, config
|
|
12
|
-
from services.content_filter import request_text
|
|
13
|
-
from services.log_service import LOG_TYPE_CALL, log_service
|
|
14
|
-
from services.protocol import openai_v1_image_edit, openai_v1_image_generations
|
|
15
|
-
|
|
16
|
-
TASK_STATUS_QUEUED = "queued"
|
|
17
|
-
TASK_STATUS_RUNNING = "running"
|
|
18
|
-
TASK_STATUS_SUCCESS = "success"
|
|
19
|
-
TASK_STATUS_ERROR = "error"
|
|
20
|
-
TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
|
|
21
|
-
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _now_iso() -> str:
|
|
25
|
-
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _timestamp(value: object) -> float:
|
|
29
|
-
if not isinstance(value, str) or not value.strip():
|
|
30
|
-
return 0.0
|
|
31
|
-
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
32
|
-
try:
|
|
33
|
-
return datetime.strptime(value[:26], fmt).timestamp()
|
|
34
|
-
except ValueError:
|
|
35
|
-
continue
|
|
36
|
-
try:
|
|
37
|
-
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
38
|
-
except Exception:
|
|
39
|
-
return 0.0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _clean(value: object, default: str = "") -> str:
|
|
43
|
-
return str(value or default).strip()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _owner_id(identity: dict[str, object]) -> str:
|
|
47
|
-
return _clean(identity.get("id")) or "anonymous"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _task_key(owner_id: str, task_id: str) -> str:
|
|
51
|
-
return f"{owner_id}:{task_id}"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _collect_image_urls(data: list[Any]) -> list[str]:
|
|
55
|
-
urls: list[str] = []
|
|
56
|
-
for item in data:
|
|
57
|
-
if isinstance(item, dict):
|
|
58
|
-
url = item.get("url")
|
|
59
|
-
if isinstance(url, str) and url:
|
|
60
|
-
urls.append(url)
|
|
61
|
-
return urls
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
-
item = {
|
|
66
|
-
"id": task.get("id"),
|
|
67
|
-
"status": task.get("status"),
|
|
68
|
-
"mode": task.get("mode"),
|
|
69
|
-
"model": task.get("model"),
|
|
70
|
-
"size": task.get("size"),
|
|
71
|
-
"quality": task.get("quality"),
|
|
72
|
-
"created_at": task.get("created_at"),
|
|
73
|
-
"updated_at": task.get("updated_at"),
|
|
74
|
-
}
|
|
75
|
-
if task.get("conversation_id"):
|
|
76
|
-
item["conversation_id"] = task.get("conversation_id")
|
|
77
|
-
if task.get("data") is not None:
|
|
78
|
-
item["data"] = task.get("data")
|
|
79
|
-
if task.get("usage") is not None:
|
|
80
|
-
item["usage"] = task.get("usage")
|
|
81
|
-
if task.get("error"):
|
|
82
|
-
item["error"] = task.get("error")
|
|
83
|
-
if task.get("progress"):
|
|
84
|
-
item["progress"] = task.get("progress")
|
|
85
|
-
if task.get("duration_ms") is not None:
|
|
86
|
-
item["duration_ms"] = task.get("duration_ms")
|
|
87
|
-
if task.get("status") in (TASK_STATUS_RUNNING, TASK_STATUS_QUEUED):
|
|
88
|
-
if task.get("status") == TASK_STATUS_RUNNING:
|
|
89
|
-
# RUNNING 状态仅在 started_ts 被设置后(image_stream_resolve_start)才计时
|
|
90
|
-
base_ts = task.get("started_ts")
|
|
91
|
-
else:
|
|
92
|
-
# QUEUED 状态从 created_ts 开始计时(排队等待中)
|
|
93
|
-
base_ts = task.get("created_ts") or task.get("updated_ts")
|
|
94
|
-
if base_ts:
|
|
95
|
-
item["elapsed_secs"] = round(time.time() - base_ts, 1)
|
|
96
|
-
return item
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class ImageTaskService:
|
|
100
|
-
def __init__(
|
|
101
|
-
self,
|
|
102
|
-
path: Path,
|
|
103
|
-
*,
|
|
104
|
-
generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
|
|
105
|
-
edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
|
|
106
|
-
retention_days_getter: Callable[[], int] | None = None,
|
|
107
|
-
):
|
|
108
|
-
self.path = path
|
|
109
|
-
self.generation_handler = generation_handler
|
|
110
|
-
self.edit_handler = edit_handler
|
|
111
|
-
self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
|
|
112
|
-
self._lock = threading.RLock()
|
|
113
|
-
self._tasks: dict[str, dict[str, Any]] = {}
|
|
114
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
with self._lock:
|
|
116
|
-
self._tasks = self._load_locked()
|
|
117
|
-
changed = self._recover_unfinished_locked()
|
|
118
|
-
changed = self._cleanup_locked() or changed
|
|
119
|
-
if changed:
|
|
120
|
-
self._save_locked()
|
|
121
|
-
|
|
122
|
-
def submit_generation(
|
|
123
|
-
self,
|
|
124
|
-
identity: dict[str, object],
|
|
125
|
-
*,
|
|
126
|
-
client_task_id: str,
|
|
127
|
-
prompt: str,
|
|
128
|
-
model: str,
|
|
129
|
-
size: str | None,
|
|
130
|
-
quality: str = "auto",
|
|
131
|
-
base_url: str = "",
|
|
132
|
-
) -> dict[str, Any]:
|
|
133
|
-
payload = {
|
|
134
|
-
"prompt": prompt,
|
|
135
|
-
"model": model,
|
|
136
|
-
"n": 1,
|
|
137
|
-
"size": size,
|
|
138
|
-
"quality": quality,
|
|
139
|
-
"response_format": "url",
|
|
140
|
-
"base_url": base_url,
|
|
141
|
-
}
|
|
142
|
-
return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
|
|
143
|
-
|
|
144
|
-
def submit_edit(
|
|
145
|
-
self,
|
|
146
|
-
identity: dict[str, object],
|
|
147
|
-
*,
|
|
148
|
-
client_task_id: str,
|
|
149
|
-
prompt: str,
|
|
150
|
-
model: str,
|
|
151
|
-
size: str | None,
|
|
152
|
-
quality: str = "auto",
|
|
153
|
-
base_url: str = "",
|
|
154
|
-
images: list[tuple[bytes, str, str]] | None = None,
|
|
155
|
-
masks: list[tuple[bytes, str, str]] | None = None,
|
|
156
|
-
) -> dict[str, Any]:
|
|
157
|
-
payload = {
|
|
158
|
-
"prompt": prompt,
|
|
159
|
-
"images": images or [],
|
|
160
|
-
"mask": masks or [],
|
|
161
|
-
"model": model,
|
|
162
|
-
"n": 1,
|
|
163
|
-
"size": size,
|
|
164
|
-
"quality": quality,
|
|
165
|
-
"response_format": "url",
|
|
166
|
-
"base_url": base_url,
|
|
167
|
-
}
|
|
168
|
-
return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
|
|
169
|
-
|
|
170
|
-
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
|
|
171
|
-
owner = _owner_id(identity)
|
|
172
|
-
requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
|
|
173
|
-
with self._lock:
|
|
174
|
-
if self._cleanup_locked():
|
|
175
|
-
self._save_locked()
|
|
176
|
-
items = []
|
|
177
|
-
missing_ids = []
|
|
178
|
-
for task_id in requested_ids:
|
|
179
|
-
task = self._tasks.get(_task_key(owner, task_id))
|
|
180
|
-
if task is None:
|
|
181
|
-
missing_ids.append(task_id)
|
|
182
|
-
else:
|
|
183
|
-
items.append(_public_task(task))
|
|
184
|
-
if not requested_ids:
|
|
185
|
-
items = [
|
|
186
|
-
_public_task(task)
|
|
187
|
-
for task in self._tasks.values()
|
|
188
|
-
if task.get("owner_id") == owner
|
|
189
|
-
]
|
|
190
|
-
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
191
|
-
missing_ids = []
|
|
192
|
-
return {"items": items, "missing_ids": missing_ids}
|
|
193
|
-
|
|
194
|
-
def _submit(
|
|
195
|
-
self,
|
|
196
|
-
identity: dict[str, object],
|
|
197
|
-
*,
|
|
198
|
-
client_task_id: str,
|
|
199
|
-
mode: str,
|
|
200
|
-
payload: dict[str, Any],
|
|
201
|
-
) -> dict[str, Any]:
|
|
202
|
-
task_id = _clean(client_task_id)
|
|
203
|
-
if not task_id:
|
|
204
|
-
raise ValueError("client_task_id is required")
|
|
205
|
-
owner = _owner_id(identity)
|
|
206
|
-
key = _task_key(owner, task_id)
|
|
207
|
-
now = _now_iso()
|
|
208
|
-
should_start = False
|
|
209
|
-
with self._lock:
|
|
210
|
-
cleaned = self._cleanup_locked()
|
|
211
|
-
task = self._tasks.get(key)
|
|
212
|
-
if task is not None:
|
|
213
|
-
if cleaned:
|
|
214
|
-
self._save_locked()
|
|
215
|
-
return _public_task(task)
|
|
216
|
-
task = {
|
|
217
|
-
"id": task_id,
|
|
218
|
-
"owner_id": owner,
|
|
219
|
-
"status": TASK_STATUS_QUEUED,
|
|
220
|
-
"mode": mode,
|
|
221
|
-
"model": _clean(payload.get("model"), "gpt-image-2"),
|
|
222
|
-
"size": _clean(payload.get("size")),
|
|
223
|
-
"quality": _clean(payload.get("quality"), "auto"),
|
|
224
|
-
"created_at": now,
|
|
225
|
-
"updated_at": now,
|
|
226
|
-
"created_ts": time.time(),
|
|
227
|
-
}
|
|
228
|
-
self._tasks[key] = task
|
|
229
|
-
self._save_locked()
|
|
230
|
-
should_start = True
|
|
231
|
-
|
|
232
|
-
if should_start:
|
|
233
|
-
thread = threading.Thread(
|
|
234
|
-
target=self._run_task,
|
|
235
|
-
args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
|
|
236
|
-
name=f"image-task-{task_id[:16]}",
|
|
237
|
-
daemon=True,
|
|
238
|
-
)
|
|
239
|
-
thread.start()
|
|
240
|
-
return _public_task(task)
|
|
241
|
-
|
|
242
|
-
def _run_task(
|
|
243
|
-
self,
|
|
244
|
-
key: str,
|
|
245
|
-
mode: str,
|
|
246
|
-
payload: dict[str, Any],
|
|
247
|
-
identity: dict[str, object],
|
|
248
|
-
model: str,
|
|
249
|
-
) -> None:
|
|
250
|
-
started = time.time()
|
|
251
|
-
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
|
252
|
-
# 创建进度回调,每个步骤完成后更新任务状态
|
|
253
|
-
def progress_callback(step: str) -> None:
|
|
254
|
-
if step == "image_stream_resolve_start":
|
|
255
|
-
self._update_task(key, started_ts=time.time())
|
|
256
|
-
self._update_task(key, progress=step)
|
|
257
|
-
# 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
|
|
258
|
-
payload_with_progress = {**payload, "progress_callback": progress_callback}
|
|
259
|
-
try:
|
|
260
|
-
handler = self.edit_handler if mode == "edit" else self.generation_handler
|
|
261
|
-
result = handler(payload_with_progress)
|
|
262
|
-
if not isinstance(result, dict):
|
|
263
|
-
raise RuntimeError("image task returned streaming result unexpectedly")
|
|
264
|
-
data = result.get("data")
|
|
265
|
-
account_email = _clean(result.get("_account_email") or result.get("account_email"))
|
|
266
|
-
if not isinstance(data, list) or not data:
|
|
267
|
-
upstream = _clean(result.get("message"))
|
|
268
|
-
if upstream:
|
|
269
|
-
message = upstream
|
|
270
|
-
else:
|
|
271
|
-
message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
|
|
272
|
-
error = RuntimeError(message)
|
|
273
|
-
if account_email:
|
|
274
|
-
setattr(error, "account_email", account_email)
|
|
275
|
-
raise error
|
|
276
|
-
usage = result.get("usage")
|
|
277
|
-
duration_ms = int((time.time() - started) * 1000)
|
|
278
|
-
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms)
|
|
279
|
-
self._log_call(
|
|
280
|
-
identity,
|
|
281
|
-
mode,
|
|
282
|
-
model,
|
|
283
|
-
started,
|
|
284
|
-
"调用完成",
|
|
285
|
-
request_preview=request_text(payload.get("prompt")),
|
|
286
|
-
urls=_collect_image_urls(data),
|
|
287
|
-
account_email=account_email,
|
|
288
|
-
)
|
|
289
|
-
except Exception as exc:
|
|
290
|
-
error_message = str(exc) or "image task failed"
|
|
291
|
-
account_email = _clean(getattr(exc, "account_email", ""))
|
|
292
|
-
conversation_id = _clean(getattr(exc, "conversation_id", ""))
|
|
293
|
-
duration_ms = int((time.time() - started) * 1000)
|
|
294
|
-
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
|
|
295
|
-
duration_ms=duration_ms,
|
|
296
|
-
**({"conversation_id": conversation_id} if conversation_id else {}))
|
|
297
|
-
self._log_call(
|
|
298
|
-
identity,
|
|
299
|
-
mode,
|
|
300
|
-
model,
|
|
301
|
-
started,
|
|
302
|
-
"调用失败",
|
|
303
|
-
request_preview=request_text(payload.get("prompt")),
|
|
304
|
-
status="failed",
|
|
305
|
-
error=error_message,
|
|
306
|
-
account_email=account_email,
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
def _log_call(
|
|
310
|
-
self,
|
|
311
|
-
identity: dict[str, object],
|
|
312
|
-
mode: str,
|
|
313
|
-
model: str,
|
|
314
|
-
started: float,
|
|
315
|
-
suffix: str,
|
|
316
|
-
*,
|
|
317
|
-
request_preview: str = "",
|
|
318
|
-
status: str = "success",
|
|
319
|
-
error: str = "",
|
|
320
|
-
urls: list[str] | None = None,
|
|
321
|
-
account_email: str = "",
|
|
322
|
-
) -> None:
|
|
323
|
-
endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
|
|
324
|
-
summary_prefix = "图生图" if mode == "edit" else "文生图"
|
|
325
|
-
detail = {
|
|
326
|
-
"key_id": identity.get("id"),
|
|
327
|
-
"key_name": identity.get("name"),
|
|
328
|
-
"role": identity.get("role"),
|
|
329
|
-
"endpoint": endpoint,
|
|
330
|
-
"model": model,
|
|
331
|
-
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
|
332
|
-
"ended_at": _now_iso(),
|
|
333
|
-
"duration_ms": int((time.time() - started) * 1000),
|
|
334
|
-
"status": status,
|
|
335
|
-
}
|
|
336
|
-
if request_preview:
|
|
337
|
-
detail["request_text"] = request_preview
|
|
338
|
-
if error:
|
|
339
|
-
detail["error"] = error
|
|
340
|
-
if account_email:
|
|
341
|
-
detail["account_email"] = account_email
|
|
342
|
-
if urls:
|
|
343
|
-
detail["urls"] = list(dict.fromkeys(urls))
|
|
344
|
-
try:
|
|
345
|
-
log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
|
|
346
|
-
except Exception:
|
|
347
|
-
pass
|
|
348
|
-
|
|
349
|
-
def _update_task(self, key: str, **updates: Any) -> None:
|
|
350
|
-
with self._lock:
|
|
351
|
-
task = self._tasks.get(key)
|
|
352
|
-
if task is None:
|
|
353
|
-
return
|
|
354
|
-
task.update(updates)
|
|
355
|
-
task["updated_at"] = _now_iso()
|
|
356
|
-
task["updated_ts"] = time.time()
|
|
357
|
-
self._save_locked()
|
|
358
|
-
|
|
359
|
-
def _load_locked(self) -> dict[str, dict[str, Any]]:
|
|
360
|
-
if not self.path.exists():
|
|
361
|
-
return {}
|
|
362
|
-
try:
|
|
363
|
-
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
364
|
-
except Exception:
|
|
365
|
-
return {}
|
|
366
|
-
raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
|
|
367
|
-
if not isinstance(raw_items, list):
|
|
368
|
-
return {}
|
|
369
|
-
tasks: dict[str, dict[str, Any]] = {}
|
|
370
|
-
for item in raw_items:
|
|
371
|
-
if not isinstance(item, dict):
|
|
372
|
-
continue
|
|
373
|
-
task_id = _clean(item.get("id"))
|
|
374
|
-
owner = _clean(item.get("owner_id"))
|
|
375
|
-
if not task_id or not owner:
|
|
376
|
-
continue
|
|
377
|
-
status = _clean(item.get("status"))
|
|
378
|
-
if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
|
|
379
|
-
status = TASK_STATUS_ERROR
|
|
380
|
-
task = {
|
|
381
|
-
"id": task_id,
|
|
382
|
-
"owner_id": owner,
|
|
383
|
-
"status": status,
|
|
384
|
-
"mode": "edit" if item.get("mode") == "edit" else "generate",
|
|
385
|
-
"model": _clean(item.get("model"), "gpt-image-2"),
|
|
386
|
-
"size": _clean(item.get("size")),
|
|
387
|
-
"quality": _clean(item.get("quality"), "auto"),
|
|
388
|
-
"created_at": _clean(item.get("created_at"), _now_iso()),
|
|
389
|
-
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
|
|
390
|
-
"created_ts": item.get("created_ts"),
|
|
391
|
-
"updated_ts": item.get("updated_ts"),
|
|
392
|
-
"started_ts": item.get("started_ts"),
|
|
393
|
-
"duration_ms": item.get("duration_ms"),
|
|
394
|
-
}
|
|
395
|
-
data = item.get("data")
|
|
396
|
-
if isinstance(data, list):
|
|
397
|
-
task["data"] = data
|
|
398
|
-
usage = item.get("usage")
|
|
399
|
-
if isinstance(usage, dict):
|
|
400
|
-
task["usage"] = usage
|
|
401
|
-
error = _clean(item.get("error"))
|
|
402
|
-
if error:
|
|
403
|
-
task["error"] = error
|
|
404
|
-
tasks[_task_key(owner, task_id)] = task
|
|
405
|
-
return tasks
|
|
406
|
-
|
|
407
|
-
def _save_locked(self) -> None:
|
|
408
|
-
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
409
|
-
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
410
|
-
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
411
|
-
tmp_path.replace(self.path)
|
|
412
|
-
|
|
413
|
-
def _recover_unfinished_locked(self) -> bool:
|
|
414
|
-
changed = False
|
|
415
|
-
for task in self._tasks.values():
|
|
416
|
-
if task.get("status") in UNFINISHED_STATUSES:
|
|
417
|
-
task["status"] = TASK_STATUS_ERROR
|
|
418
|
-
task["error"] = "服务已重启,未完成的图片任务已中断"
|
|
419
|
-
task["updated_at"] = _now_iso()
|
|
420
|
-
changed = True
|
|
421
|
-
return changed
|
|
422
|
-
|
|
423
|
-
def _cleanup_locked(self) -> bool:
|
|
424
|
-
try:
|
|
425
|
-
retention_days = max(1, int(self.retention_days_getter()))
|
|
426
|
-
except Exception:
|
|
427
|
-
retention_days = 30
|
|
428
|
-
cutoff = time.time() - retention_days * 86400
|
|
429
|
-
removed_keys = [
|
|
430
|
-
key
|
|
431
|
-
for key, task in self._tasks.items()
|
|
432
|
-
if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
|
|
433
|
-
]
|
|
434
|
-
for key in removed_keys:
|
|
435
|
-
self._tasks.pop(key, None)
|
|
436
|
-
return bool(removed_keys)
|
|
437
|
-
|
|
438
|
-
def resume_poll(
|
|
439
|
-
self,
|
|
440
|
-
identity: dict[str, object],
|
|
441
|
-
task_id: str,
|
|
442
|
-
extra_timeout_secs: float = 30.0,
|
|
443
|
-
) -> dict[str, Any]:
|
|
444
|
-
"""恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
|
|
445
|
-
owner = _owner_id(identity)
|
|
446
|
-
key = _task_key(owner, _clean(task_id))
|
|
447
|
-
with self._lock:
|
|
448
|
-
task = self._tasks.get(key)
|
|
449
|
-
if task is None:
|
|
450
|
-
raise ValueError("task not found")
|
|
451
|
-
if task.get("status") != TASK_STATUS_ERROR:
|
|
452
|
-
raise ValueError("task is not in error state")
|
|
453
|
-
error_msg = _clean(task.get("error"))
|
|
454
|
-
if "超时" not in error_msg:
|
|
455
|
-
raise ValueError("task error is not a timeout error")
|
|
456
|
-
conversation_id = _clean(task.get("conversation_id"))
|
|
457
|
-
if not conversation_id:
|
|
458
|
-
raise ValueError("task has no conversation_id")
|
|
459
|
-
mode = task.get("mode", "generate")
|
|
460
|
-
model = task.get("model", "gpt-image-2")
|
|
461
|
-
# 将任务状态重置为 running
|
|
462
|
-
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
|
463
|
-
|
|
464
|
-
# 启动新线程继续轮询
|
|
465
|
-
thread = threading.Thread(
|
|
466
|
-
target=self._run_resume_poll,
|
|
467
|
-
args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
|
|
468
|
-
name=f"image-resume-{_clean(task_id)[:16]}",
|
|
469
|
-
daemon=True,
|
|
470
|
-
)
|
|
471
|
-
thread.start()
|
|
472
|
-
return _public_task(task)
|
|
473
|
-
|
|
474
|
-
def _run_resume_poll(
|
|
475
|
-
self,
|
|
476
|
-
key: str,
|
|
477
|
-
conversation_id: str,
|
|
478
|
-
extra_timeout_secs: float,
|
|
479
|
-
identity: dict[str, object],
|
|
480
|
-
mode: str,
|
|
481
|
-
model: str,
|
|
482
|
-
) -> None:
|
|
483
|
-
"""后台线程:继续轮询已有 conversation_id 的图片结果。"""
|
|
484
|
-
started = time.time()
|
|
485
|
-
backend = None
|
|
486
|
-
try:
|
|
487
|
-
from services.openai_backend_api import OpenAIBackendAPI
|
|
488
|
-
from services.protocol.conversation import format_image_result
|
|
489
|
-
|
|
490
|
-
backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
|
|
491
|
-
file_ids, sediment_ids = backend._poll_image_results(
|
|
492
|
-
conversation_id,
|
|
493
|
-
extra_timeout_secs,
|
|
494
|
-
)
|
|
495
|
-
if not file_ids and not sediment_ids:
|
|
496
|
-
raise RuntimeError(
|
|
497
|
-
f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
|
|
498
|
-
)
|
|
499
|
-
|
|
500
|
-
image_urls = backend.resolve_conversation_image_urls(
|
|
501
|
-
conversation_id, file_ids, sediment_ids, poll=False,
|
|
502
|
-
)
|
|
503
|
-
if not image_urls:
|
|
504
|
-
raise RuntimeError("图片 URL 解析失败")
|
|
505
|
-
|
|
506
|
-
image_items = [
|
|
507
|
-
{"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
|
|
508
|
-
for image_data in backend.download_image_bytes(image_urls)
|
|
509
|
-
]
|
|
510
|
-
# 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
|
|
511
|
-
with self._lock:
|
|
512
|
-
task = self._tasks.get(key)
|
|
513
|
-
quality = _clean(task.get("quality"), "auto") if task else "auto"
|
|
514
|
-
size = _clean(task.get("size")) if task else None
|
|
515
|
-
data = format_image_result(
|
|
516
|
-
image_items,
|
|
517
|
-
"", # prompt 已不重要,结果已经拿到了
|
|
518
|
-
"b64_json",
|
|
519
|
-
"",
|
|
520
|
-
int(time.time()),
|
|
521
|
-
)["data"]
|
|
522
|
-
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
|
|
523
|
-
self._log_call(
|
|
524
|
-
identity,
|
|
525
|
-
mode,
|
|
526
|
-
model,
|
|
527
|
-
started,
|
|
528
|
-
"调用完成(续轮询)",
|
|
529
|
-
status="success",
|
|
530
|
-
urls=_collect_image_urls(data),
|
|
531
|
-
)
|
|
532
|
-
except Exception as exc:
|
|
533
|
-
error_message = str(exc) or "resume poll failed"
|
|
534
|
-
duration_ms = int((time.time() - started) * 1000)
|
|
535
|
-
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
|
|
536
|
-
self._log_call(
|
|
537
|
-
identity,
|
|
538
|
-
mode,
|
|
539
|
-
model,
|
|
540
|
-
started,
|
|
541
|
-
"调用失败(续轮询)",
|
|
542
|
-
status="failed",
|
|
543
|
-
error=error_message,
|
|
544
|
-
)
|
|
545
|
-
finally:
|
|
546
|
-
if backend is not None:
|
|
547
|
-
backend.close()
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from services.config import DATA_DIR, config
|
|
12
|
+
from services.content_filter import request_text
|
|
13
|
+
from services.log_service import LOG_TYPE_CALL, log_service
|
|
14
|
+
from services.protocol import openai_v1_image_edit, openai_v1_image_generations
|
|
15
|
+
|
|
16
|
+
TASK_STATUS_QUEUED = "queued"
|
|
17
|
+
TASK_STATUS_RUNNING = "running"
|
|
18
|
+
TASK_STATUS_SUCCESS = "success"
|
|
19
|
+
TASK_STATUS_ERROR = "error"
|
|
20
|
+
TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
|
|
21
|
+
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _now_iso() -> str:
|
|
25
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _timestamp(value: object) -> float:
|
|
29
|
+
if not isinstance(value, str) or not value.strip():
|
|
30
|
+
return 0.0
|
|
31
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
32
|
+
try:
|
|
33
|
+
return datetime.strptime(value[:26], fmt).timestamp()
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
38
|
+
except Exception:
|
|
39
|
+
return 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean(value: object, default: str = "") -> str:
|
|
43
|
+
return str(value or default).strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _owner_id(identity: dict[str, object]) -> str:
|
|
47
|
+
return _clean(identity.get("id")) or "anonymous"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _task_key(owner_id: str, task_id: str) -> str:
|
|
51
|
+
return f"{owner_id}:{task_id}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _collect_image_urls(data: list[Any]) -> list[str]:
|
|
55
|
+
urls: list[str] = []
|
|
56
|
+
for item in data:
|
|
57
|
+
if isinstance(item, dict):
|
|
58
|
+
url = item.get("url")
|
|
59
|
+
if isinstance(url, str) and url:
|
|
60
|
+
urls.append(url)
|
|
61
|
+
return urls
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
item = {
|
|
66
|
+
"id": task.get("id"),
|
|
67
|
+
"status": task.get("status"),
|
|
68
|
+
"mode": task.get("mode"),
|
|
69
|
+
"model": task.get("model"),
|
|
70
|
+
"size": task.get("size"),
|
|
71
|
+
"quality": task.get("quality"),
|
|
72
|
+
"created_at": task.get("created_at"),
|
|
73
|
+
"updated_at": task.get("updated_at"),
|
|
74
|
+
}
|
|
75
|
+
if task.get("conversation_id"):
|
|
76
|
+
item["conversation_id"] = task.get("conversation_id")
|
|
77
|
+
if task.get("data") is not None:
|
|
78
|
+
item["data"] = task.get("data")
|
|
79
|
+
if task.get("usage") is not None:
|
|
80
|
+
item["usage"] = task.get("usage")
|
|
81
|
+
if task.get("error"):
|
|
82
|
+
item["error"] = task.get("error")
|
|
83
|
+
if task.get("progress"):
|
|
84
|
+
item["progress"] = task.get("progress")
|
|
85
|
+
if task.get("duration_ms") is not None:
|
|
86
|
+
item["duration_ms"] = task.get("duration_ms")
|
|
87
|
+
if task.get("status") in (TASK_STATUS_RUNNING, TASK_STATUS_QUEUED):
|
|
88
|
+
if task.get("status") == TASK_STATUS_RUNNING:
|
|
89
|
+
# RUNNING 状态仅在 started_ts 被设置后(image_stream_resolve_start)才计时
|
|
90
|
+
base_ts = task.get("started_ts")
|
|
91
|
+
else:
|
|
92
|
+
# QUEUED 状态从 created_ts 开始计时(排队等待中)
|
|
93
|
+
base_ts = task.get("created_ts") or task.get("updated_ts")
|
|
94
|
+
if base_ts:
|
|
95
|
+
item["elapsed_secs"] = round(time.time() - base_ts, 1)
|
|
96
|
+
return item
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ImageTaskService:
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
path: Path,
|
|
103
|
+
*,
|
|
104
|
+
generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
|
|
105
|
+
edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
|
|
106
|
+
retention_days_getter: Callable[[], int] | None = None,
|
|
107
|
+
):
|
|
108
|
+
self.path = path
|
|
109
|
+
self.generation_handler = generation_handler
|
|
110
|
+
self.edit_handler = edit_handler
|
|
111
|
+
self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
|
|
112
|
+
self._lock = threading.RLock()
|
|
113
|
+
self._tasks: dict[str, dict[str, Any]] = {}
|
|
114
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._tasks = self._load_locked()
|
|
117
|
+
changed = self._recover_unfinished_locked()
|
|
118
|
+
changed = self._cleanup_locked() or changed
|
|
119
|
+
if changed:
|
|
120
|
+
self._save_locked()
|
|
121
|
+
|
|
122
|
+
def submit_generation(
|
|
123
|
+
self,
|
|
124
|
+
identity: dict[str, object],
|
|
125
|
+
*,
|
|
126
|
+
client_task_id: str,
|
|
127
|
+
prompt: str,
|
|
128
|
+
model: str,
|
|
129
|
+
size: str | None,
|
|
130
|
+
quality: str = "auto",
|
|
131
|
+
base_url: str = "",
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
payload = {
|
|
134
|
+
"prompt": prompt,
|
|
135
|
+
"model": model,
|
|
136
|
+
"n": 1,
|
|
137
|
+
"size": size,
|
|
138
|
+
"quality": quality,
|
|
139
|
+
"response_format": "url",
|
|
140
|
+
"base_url": base_url,
|
|
141
|
+
}
|
|
142
|
+
return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
|
|
143
|
+
|
|
144
|
+
def submit_edit(
|
|
145
|
+
self,
|
|
146
|
+
identity: dict[str, object],
|
|
147
|
+
*,
|
|
148
|
+
client_task_id: str,
|
|
149
|
+
prompt: str,
|
|
150
|
+
model: str,
|
|
151
|
+
size: str | None,
|
|
152
|
+
quality: str = "auto",
|
|
153
|
+
base_url: str = "",
|
|
154
|
+
images: list[tuple[bytes, str, str]] | None = None,
|
|
155
|
+
masks: list[tuple[bytes, str, str]] | None = None,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
payload = {
|
|
158
|
+
"prompt": prompt,
|
|
159
|
+
"images": images or [],
|
|
160
|
+
"mask": masks or [],
|
|
161
|
+
"model": model,
|
|
162
|
+
"n": 1,
|
|
163
|
+
"size": size,
|
|
164
|
+
"quality": quality,
|
|
165
|
+
"response_format": "url",
|
|
166
|
+
"base_url": base_url,
|
|
167
|
+
}
|
|
168
|
+
return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
|
|
169
|
+
|
|
170
|
+
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
|
|
171
|
+
owner = _owner_id(identity)
|
|
172
|
+
requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
|
|
173
|
+
with self._lock:
|
|
174
|
+
if self._cleanup_locked():
|
|
175
|
+
self._save_locked()
|
|
176
|
+
items = []
|
|
177
|
+
missing_ids = []
|
|
178
|
+
for task_id in requested_ids:
|
|
179
|
+
task = self._tasks.get(_task_key(owner, task_id))
|
|
180
|
+
if task is None:
|
|
181
|
+
missing_ids.append(task_id)
|
|
182
|
+
else:
|
|
183
|
+
items.append(_public_task(task))
|
|
184
|
+
if not requested_ids:
|
|
185
|
+
items = [
|
|
186
|
+
_public_task(task)
|
|
187
|
+
for task in self._tasks.values()
|
|
188
|
+
if task.get("owner_id") == owner
|
|
189
|
+
]
|
|
190
|
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
191
|
+
missing_ids = []
|
|
192
|
+
return {"items": items, "missing_ids": missing_ids}
|
|
193
|
+
|
|
194
|
+
def _submit(
|
|
195
|
+
self,
|
|
196
|
+
identity: dict[str, object],
|
|
197
|
+
*,
|
|
198
|
+
client_task_id: str,
|
|
199
|
+
mode: str,
|
|
200
|
+
payload: dict[str, Any],
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
task_id = _clean(client_task_id)
|
|
203
|
+
if not task_id:
|
|
204
|
+
raise ValueError("client_task_id is required")
|
|
205
|
+
owner = _owner_id(identity)
|
|
206
|
+
key = _task_key(owner, task_id)
|
|
207
|
+
now = _now_iso()
|
|
208
|
+
should_start = False
|
|
209
|
+
with self._lock:
|
|
210
|
+
cleaned = self._cleanup_locked()
|
|
211
|
+
task = self._tasks.get(key)
|
|
212
|
+
if task is not None:
|
|
213
|
+
if cleaned:
|
|
214
|
+
self._save_locked()
|
|
215
|
+
return _public_task(task)
|
|
216
|
+
task = {
|
|
217
|
+
"id": task_id,
|
|
218
|
+
"owner_id": owner,
|
|
219
|
+
"status": TASK_STATUS_QUEUED,
|
|
220
|
+
"mode": mode,
|
|
221
|
+
"model": _clean(payload.get("model"), "gpt-image-2"),
|
|
222
|
+
"size": _clean(payload.get("size")),
|
|
223
|
+
"quality": _clean(payload.get("quality"), "auto"),
|
|
224
|
+
"created_at": now,
|
|
225
|
+
"updated_at": now,
|
|
226
|
+
"created_ts": time.time(),
|
|
227
|
+
}
|
|
228
|
+
self._tasks[key] = task
|
|
229
|
+
self._save_locked()
|
|
230
|
+
should_start = True
|
|
231
|
+
|
|
232
|
+
if should_start:
|
|
233
|
+
thread = threading.Thread(
|
|
234
|
+
target=self._run_task,
|
|
235
|
+
args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
|
|
236
|
+
name=f"image-task-{task_id[:16]}",
|
|
237
|
+
daemon=True,
|
|
238
|
+
)
|
|
239
|
+
thread.start()
|
|
240
|
+
return _public_task(task)
|
|
241
|
+
|
|
242
|
+
def _run_task(
|
|
243
|
+
self,
|
|
244
|
+
key: str,
|
|
245
|
+
mode: str,
|
|
246
|
+
payload: dict[str, Any],
|
|
247
|
+
identity: dict[str, object],
|
|
248
|
+
model: str,
|
|
249
|
+
) -> None:
|
|
250
|
+
started = time.time()
|
|
251
|
+
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
|
252
|
+
# 创建进度回调,每个步骤完成后更新任务状态
|
|
253
|
+
def progress_callback(step: str) -> None:
|
|
254
|
+
if step == "image_stream_resolve_start":
|
|
255
|
+
self._update_task(key, started_ts=time.time())
|
|
256
|
+
self._update_task(key, progress=step)
|
|
257
|
+
# 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
|
|
258
|
+
payload_with_progress = {**payload, "progress_callback": progress_callback}
|
|
259
|
+
try:
|
|
260
|
+
handler = self.edit_handler if mode == "edit" else self.generation_handler
|
|
261
|
+
result = handler(payload_with_progress)
|
|
262
|
+
if not isinstance(result, dict):
|
|
263
|
+
raise RuntimeError("image task returned streaming result unexpectedly")
|
|
264
|
+
data = result.get("data")
|
|
265
|
+
account_email = _clean(result.get("_account_email") or result.get("account_email"))
|
|
266
|
+
if not isinstance(data, list) or not data:
|
|
267
|
+
upstream = _clean(result.get("message"))
|
|
268
|
+
if upstream:
|
|
269
|
+
message = upstream
|
|
270
|
+
else:
|
|
271
|
+
message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
|
|
272
|
+
error = RuntimeError(message)
|
|
273
|
+
if account_email:
|
|
274
|
+
setattr(error, "account_email", account_email)
|
|
275
|
+
raise error
|
|
276
|
+
usage = result.get("usage")
|
|
277
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
278
|
+
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms)
|
|
279
|
+
self._log_call(
|
|
280
|
+
identity,
|
|
281
|
+
mode,
|
|
282
|
+
model,
|
|
283
|
+
started,
|
|
284
|
+
"调用完成",
|
|
285
|
+
request_preview=request_text(payload.get("prompt")),
|
|
286
|
+
urls=_collect_image_urls(data),
|
|
287
|
+
account_email=account_email,
|
|
288
|
+
)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
error_message = str(exc) or "image task failed"
|
|
291
|
+
account_email = _clean(getattr(exc, "account_email", ""))
|
|
292
|
+
conversation_id = _clean(getattr(exc, "conversation_id", ""))
|
|
293
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
294
|
+
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
|
|
295
|
+
duration_ms=duration_ms,
|
|
296
|
+
**({"conversation_id": conversation_id} if conversation_id else {}))
|
|
297
|
+
self._log_call(
|
|
298
|
+
identity,
|
|
299
|
+
mode,
|
|
300
|
+
model,
|
|
301
|
+
started,
|
|
302
|
+
"调用失败",
|
|
303
|
+
request_preview=request_text(payload.get("prompt")),
|
|
304
|
+
status="failed",
|
|
305
|
+
error=error_message,
|
|
306
|
+
account_email=account_email,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _log_call(
|
|
310
|
+
self,
|
|
311
|
+
identity: dict[str, object],
|
|
312
|
+
mode: str,
|
|
313
|
+
model: str,
|
|
314
|
+
started: float,
|
|
315
|
+
suffix: str,
|
|
316
|
+
*,
|
|
317
|
+
request_preview: str = "",
|
|
318
|
+
status: str = "success",
|
|
319
|
+
error: str = "",
|
|
320
|
+
urls: list[str] | None = None,
|
|
321
|
+
account_email: str = "",
|
|
322
|
+
) -> None:
|
|
323
|
+
endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
|
|
324
|
+
summary_prefix = "图生图" if mode == "edit" else "文生图"
|
|
325
|
+
detail = {
|
|
326
|
+
"key_id": identity.get("id"),
|
|
327
|
+
"key_name": identity.get("name"),
|
|
328
|
+
"role": identity.get("role"),
|
|
329
|
+
"endpoint": endpoint,
|
|
330
|
+
"model": model,
|
|
331
|
+
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
|
332
|
+
"ended_at": _now_iso(),
|
|
333
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
334
|
+
"status": status,
|
|
335
|
+
}
|
|
336
|
+
if request_preview:
|
|
337
|
+
detail["request_text"] = request_preview
|
|
338
|
+
if error:
|
|
339
|
+
detail["error"] = error
|
|
340
|
+
if account_email:
|
|
341
|
+
detail["account_email"] = account_email
|
|
342
|
+
if urls:
|
|
343
|
+
detail["urls"] = list(dict.fromkeys(urls))
|
|
344
|
+
try:
|
|
345
|
+
log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
def _update_task(self, key: str, **updates: Any) -> None:
|
|
350
|
+
with self._lock:
|
|
351
|
+
task = self._tasks.get(key)
|
|
352
|
+
if task is None:
|
|
353
|
+
return
|
|
354
|
+
task.update(updates)
|
|
355
|
+
task["updated_at"] = _now_iso()
|
|
356
|
+
task["updated_ts"] = time.time()
|
|
357
|
+
self._save_locked()
|
|
358
|
+
|
|
359
|
+
def _load_locked(self) -> dict[str, dict[str, Any]]:
|
|
360
|
+
if not self.path.exists():
|
|
361
|
+
return {}
|
|
362
|
+
try:
|
|
363
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
364
|
+
except Exception:
|
|
365
|
+
return {}
|
|
366
|
+
raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
|
|
367
|
+
if not isinstance(raw_items, list):
|
|
368
|
+
return {}
|
|
369
|
+
tasks: dict[str, dict[str, Any]] = {}
|
|
370
|
+
for item in raw_items:
|
|
371
|
+
if not isinstance(item, dict):
|
|
372
|
+
continue
|
|
373
|
+
task_id = _clean(item.get("id"))
|
|
374
|
+
owner = _clean(item.get("owner_id"))
|
|
375
|
+
if not task_id or not owner:
|
|
376
|
+
continue
|
|
377
|
+
status = _clean(item.get("status"))
|
|
378
|
+
if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
|
|
379
|
+
status = TASK_STATUS_ERROR
|
|
380
|
+
task = {
|
|
381
|
+
"id": task_id,
|
|
382
|
+
"owner_id": owner,
|
|
383
|
+
"status": status,
|
|
384
|
+
"mode": "edit" if item.get("mode") == "edit" else "generate",
|
|
385
|
+
"model": _clean(item.get("model"), "gpt-image-2"),
|
|
386
|
+
"size": _clean(item.get("size")),
|
|
387
|
+
"quality": _clean(item.get("quality"), "auto"),
|
|
388
|
+
"created_at": _clean(item.get("created_at"), _now_iso()),
|
|
389
|
+
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
|
|
390
|
+
"created_ts": item.get("created_ts"),
|
|
391
|
+
"updated_ts": item.get("updated_ts"),
|
|
392
|
+
"started_ts": item.get("started_ts"),
|
|
393
|
+
"duration_ms": item.get("duration_ms"),
|
|
394
|
+
}
|
|
395
|
+
data = item.get("data")
|
|
396
|
+
if isinstance(data, list):
|
|
397
|
+
task["data"] = data
|
|
398
|
+
usage = item.get("usage")
|
|
399
|
+
if isinstance(usage, dict):
|
|
400
|
+
task["usage"] = usage
|
|
401
|
+
error = _clean(item.get("error"))
|
|
402
|
+
if error:
|
|
403
|
+
task["error"] = error
|
|
404
|
+
tasks[_task_key(owner, task_id)] = task
|
|
405
|
+
return tasks
|
|
406
|
+
|
|
407
|
+
def _save_locked(self) -> None:
|
|
408
|
+
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
409
|
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
410
|
+
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
411
|
+
tmp_path.replace(self.path)
|
|
412
|
+
|
|
413
|
+
def _recover_unfinished_locked(self) -> bool:
|
|
414
|
+
changed = False
|
|
415
|
+
for task in self._tasks.values():
|
|
416
|
+
if task.get("status") in UNFINISHED_STATUSES:
|
|
417
|
+
task["status"] = TASK_STATUS_ERROR
|
|
418
|
+
task["error"] = "服务已重启,未完成的图片任务已中断"
|
|
419
|
+
task["updated_at"] = _now_iso()
|
|
420
|
+
changed = True
|
|
421
|
+
return changed
|
|
422
|
+
|
|
423
|
+
def _cleanup_locked(self) -> bool:
|
|
424
|
+
try:
|
|
425
|
+
retention_days = max(1, int(self.retention_days_getter()))
|
|
426
|
+
except Exception:
|
|
427
|
+
retention_days = 30
|
|
428
|
+
cutoff = time.time() - retention_days * 86400
|
|
429
|
+
removed_keys = [
|
|
430
|
+
key
|
|
431
|
+
for key, task in self._tasks.items()
|
|
432
|
+
if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
|
|
433
|
+
]
|
|
434
|
+
for key in removed_keys:
|
|
435
|
+
self._tasks.pop(key, None)
|
|
436
|
+
return bool(removed_keys)
|
|
437
|
+
|
|
438
|
+
def resume_poll(
|
|
439
|
+
self,
|
|
440
|
+
identity: dict[str, object],
|
|
441
|
+
task_id: str,
|
|
442
|
+
extra_timeout_secs: float = 30.0,
|
|
443
|
+
) -> dict[str, Any]:
|
|
444
|
+
"""恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
|
|
445
|
+
owner = _owner_id(identity)
|
|
446
|
+
key = _task_key(owner, _clean(task_id))
|
|
447
|
+
with self._lock:
|
|
448
|
+
task = self._tasks.get(key)
|
|
449
|
+
if task is None:
|
|
450
|
+
raise ValueError("task not found")
|
|
451
|
+
if task.get("status") != TASK_STATUS_ERROR:
|
|
452
|
+
raise ValueError("task is not in error state")
|
|
453
|
+
error_msg = _clean(task.get("error"))
|
|
454
|
+
if "超时" not in error_msg:
|
|
455
|
+
raise ValueError("task error is not a timeout error")
|
|
456
|
+
conversation_id = _clean(task.get("conversation_id"))
|
|
457
|
+
if not conversation_id:
|
|
458
|
+
raise ValueError("task has no conversation_id")
|
|
459
|
+
mode = task.get("mode", "generate")
|
|
460
|
+
model = task.get("model", "gpt-image-2")
|
|
461
|
+
# 将任务状态重置为 running
|
|
462
|
+
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
|
463
|
+
|
|
464
|
+
# 启动新线程继续轮询
|
|
465
|
+
thread = threading.Thread(
|
|
466
|
+
target=self._run_resume_poll,
|
|
467
|
+
args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
|
|
468
|
+
name=f"image-resume-{_clean(task_id)[:16]}",
|
|
469
|
+
daemon=True,
|
|
470
|
+
)
|
|
471
|
+
thread.start()
|
|
472
|
+
return _public_task(task)
|
|
473
|
+
|
|
474
|
+
def _run_resume_poll(
|
|
475
|
+
self,
|
|
476
|
+
key: str,
|
|
477
|
+
conversation_id: str,
|
|
478
|
+
extra_timeout_secs: float,
|
|
479
|
+
identity: dict[str, object],
|
|
480
|
+
mode: str,
|
|
481
|
+
model: str,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""后台线程:继续轮询已有 conversation_id 的图片结果。"""
|
|
484
|
+
started = time.time()
|
|
485
|
+
backend = None
|
|
486
|
+
try:
|
|
487
|
+
from services.openai_backend_api import OpenAIBackendAPI
|
|
488
|
+
from services.protocol.conversation import format_image_result
|
|
489
|
+
|
|
490
|
+
backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
|
|
491
|
+
file_ids, sediment_ids = backend._poll_image_results(
|
|
492
|
+
conversation_id,
|
|
493
|
+
extra_timeout_secs,
|
|
494
|
+
)
|
|
495
|
+
if not file_ids and not sediment_ids:
|
|
496
|
+
raise RuntimeError(
|
|
497
|
+
f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
image_urls = backend.resolve_conversation_image_urls(
|
|
501
|
+
conversation_id, file_ids, sediment_ids, poll=False,
|
|
502
|
+
)
|
|
503
|
+
if not image_urls:
|
|
504
|
+
raise RuntimeError("图片 URL 解析失败")
|
|
505
|
+
|
|
506
|
+
image_items = [
|
|
507
|
+
{"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
|
|
508
|
+
for image_data in backend.download_image_bytes(image_urls)
|
|
509
|
+
]
|
|
510
|
+
# 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
|
|
511
|
+
with self._lock:
|
|
512
|
+
task = self._tasks.get(key)
|
|
513
|
+
quality = _clean(task.get("quality"), "auto") if task else "auto"
|
|
514
|
+
size = _clean(task.get("size")) if task else None
|
|
515
|
+
data = format_image_result(
|
|
516
|
+
image_items,
|
|
517
|
+
"", # prompt 已不重要,结果已经拿到了
|
|
518
|
+
"b64_json",
|
|
519
|
+
"",
|
|
520
|
+
int(time.time()),
|
|
521
|
+
)["data"]
|
|
522
|
+
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
|
|
523
|
+
self._log_call(
|
|
524
|
+
identity,
|
|
525
|
+
mode,
|
|
526
|
+
model,
|
|
527
|
+
started,
|
|
528
|
+
"调用完成(续轮询)",
|
|
529
|
+
status="success",
|
|
530
|
+
urls=_collect_image_urls(data),
|
|
531
|
+
)
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
error_message = str(exc) or "resume poll failed"
|
|
534
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
535
|
+
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
|
|
536
|
+
self._log_call(
|
|
537
|
+
identity,
|
|
538
|
+
mode,
|
|
539
|
+
model,
|
|
540
|
+
started,
|
|
541
|
+
"调用失败(续轮询)",
|
|
542
|
+
status="failed",
|
|
543
|
+
error=error_message,
|
|
544
|
+
)
|
|
545
|
+
finally:
|
|
546
|
+
if backend is not None:
|
|
547
|
+
backend.close()
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
|