Lyonle commited on
Commit
5a897fc
·
1 Parent(s): 3ad09d8

fix(image): persist image tasks across refreshes

Browse files

Add an internal idempotent image task API that stores task results in data/image_tasks.json, update the image page to poll and restore task results after refresh or navigation, and cover the task service/API with tests.

Refs basketikun/chatgpt2api#65

api/app.py CHANGED
@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
8
  from fastapi.responses import FileResponse
9
  from fastapi.staticfiles import StaticFiles
10
 
11
- from api import accounts, ai, register, system
12
  from api.support import resolve_web_asset, start_limited_account_watcher
13
  from services.config import config
14
 
@@ -37,6 +37,7 @@ def create_app() -> FastAPI:
37
  )
38
  app.include_router(ai.create_router())
39
  app.include_router(accounts.create_router())
 
40
  app.include_router(register.create_router())
41
  app.include_router(system.create_router(app_version))
42
  if config.images_dir.exists():
 
8
  from fastapi.responses import FileResponse
9
  from fastapi.staticfiles import StaticFiles
10
 
11
+ from api import accounts, ai, image_tasks, register, system
12
  from api.support import resolve_web_asset, start_limited_account_watcher
13
  from services.config import config
14
 
 
37
  )
38
  app.include_router(ai.create_router())
39
  app.include_router(accounts.create_router())
40
+ app.include_router(image_tasks.create_router())
41
  app.include_router(register.create_router())
42
  app.include_router(system.create_router(app_version))
43
  if config.images_dir.exists():
api/image_tasks.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, File, Form, Header, HTTPException, Query, Request, UploadFile
4
+ from fastapi.concurrency import run_in_threadpool
5
+ from pydantic import BaseModel, Field
6
+
7
+ from api.support import require_identity, resolve_image_base_url
8
+ from services.image_task_service import image_task_service
9
+
10
+
11
+ class ImageGenerationTaskRequest(BaseModel):
12
+ client_task_id: str = Field(..., min_length=1)
13
+ prompt: str = Field(..., min_length=1)
14
+ model: str = "gpt-image-2"
15
+ size: str | None = None
16
+
17
+
18
+ def _parse_task_ids(value: str) -> list[str]:
19
+ return [item.strip() for item in value.split(",") if item.strip()]
20
+
21
+
22
+ def create_router() -> APIRouter:
23
+ router = APIRouter()
24
+
25
+ @router.get("/api/image-tasks")
26
+ async def list_image_tasks(
27
+ ids: str = Query(default=""),
28
+ authorization: str | None = Header(default=None),
29
+ ):
30
+ identity = require_identity(authorization)
31
+ return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
32
+
33
+ @router.post("/api/image-tasks/generations")
34
+ async def create_generation_task(
35
+ body: ImageGenerationTaskRequest,
36
+ request: Request,
37
+ authorization: str | None = Header(default=None),
38
+ ):
39
+ identity = require_identity(authorization)
40
+ try:
41
+ return await run_in_threadpool(
42
+ image_task_service.submit_generation,
43
+ identity,
44
+ client_task_id=body.client_task_id,
45
+ prompt=body.prompt,
46
+ model=body.model,
47
+ size=body.size,
48
+ base_url=resolve_image_base_url(request),
49
+ )
50
+ except ValueError as exc:
51
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
52
+
53
+ @router.post("/api/image-tasks/edits")
54
+ async def create_edit_task(
55
+ request: Request,
56
+ authorization: str | None = Header(default=None),
57
+ image: list[UploadFile] | None = File(default=None),
58
+ image_list: list[UploadFile] | None = File(default=None, alias="image[]"),
59
+ client_task_id: str = Form(...),
60
+ prompt: str = Form(...),
61
+ model: str = Form(default="gpt-image-2"),
62
+ size: str | None = Form(default=None),
63
+ ):
64
+ identity = require_identity(authorization)
65
+ uploads = [*(image or []), *(image_list or [])]
66
+ if not uploads:
67
+ raise HTTPException(status_code=400, detail={"error": "image file is required"})
68
+ images: list[tuple[bytes, str, str]] = []
69
+ for upload in uploads:
70
+ image_data = await upload.read()
71
+ if not image_data:
72
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
73
+ images.append((image_data, upload.filename or "image.png", upload.content_type or "image/png"))
74
+ try:
75
+ return await run_in_threadpool(
76
+ image_task_service.submit_edit,
77
+ identity,
78
+ client_task_id=client_task_id,
79
+ prompt=prompt,
80
+ model=model,
81
+ size=size,
82
+ base_url=resolve_image_base_url(request),
83
+ images=images,
84
+ )
85
+ except ValueError as exc:
86
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
87
+
88
+ return router
services/image_task_service.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.protocol import openai_v1_image_edit, openai_v1_image_generations
13
+
14
+ TASK_STATUS_QUEUED = "queued"
15
+ TASK_STATUS_RUNNING = "running"
16
+ TASK_STATUS_SUCCESS = "success"
17
+ TASK_STATUS_ERROR = "error"
18
+ TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
19
+ UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
20
+
21
+
22
+ def _now_iso() -> str:
23
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
24
+
25
+
26
+ def _timestamp(value: object) -> float:
27
+ if not isinstance(value, str) or not value.strip():
28
+ return 0.0
29
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
30
+ try:
31
+ return datetime.strptime(value[:26], fmt).timestamp()
32
+ except ValueError:
33
+ continue
34
+ try:
35
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
36
+ except Exception:
37
+ return 0.0
38
+
39
+
40
+ def _clean(value: object, default: str = "") -> str:
41
+ return str(value or default).strip()
42
+
43
+
44
+ def _owner_id(identity: dict[str, object]) -> str:
45
+ return _clean(identity.get("id")) or "anonymous"
46
+
47
+
48
+ def _task_key(owner_id: str, task_id: str) -> str:
49
+ return f"{owner_id}:{task_id}"
50
+
51
+
52
+ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
53
+ item = {
54
+ "id": task.get("id"),
55
+ "status": task.get("status"),
56
+ "mode": task.get("mode"),
57
+ "model": task.get("model"),
58
+ "size": task.get("size"),
59
+ "created_at": task.get("created_at"),
60
+ "updated_at": task.get("updated_at"),
61
+ }
62
+ if task.get("data") is not None:
63
+ item["data"] = task.get("data")
64
+ if task.get("error"):
65
+ item["error"] = task.get("error")
66
+ return item
67
+
68
+
69
+ class ImageTaskService:
70
+ def __init__(
71
+ self,
72
+ path: Path,
73
+ *,
74
+ generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
75
+ edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
76
+ retention_days_getter: Callable[[], int] | None = None,
77
+ ):
78
+ self.path = path
79
+ self.generation_handler = generation_handler
80
+ self.edit_handler = edit_handler
81
+ self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
82
+ self._lock = threading.RLock()
83
+ self._tasks: dict[str, dict[str, Any]] = {}
84
+ self.path.parent.mkdir(parents=True, exist_ok=True)
85
+ with self._lock:
86
+ self._tasks = self._load_locked()
87
+ changed = self._recover_unfinished_locked()
88
+ changed = self._cleanup_locked() or changed
89
+ if changed:
90
+ self._save_locked()
91
+
92
+ def submit_generation(
93
+ self,
94
+ identity: dict[str, object],
95
+ *,
96
+ client_task_id: str,
97
+ prompt: str,
98
+ model: str,
99
+ size: str | None,
100
+ base_url: str,
101
+ ) -> dict[str, Any]:
102
+ payload = {
103
+ "prompt": prompt,
104
+ "model": model,
105
+ "n": 1,
106
+ "size": size,
107
+ "response_format": "url",
108
+ "base_url": base_url,
109
+ }
110
+ return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
111
+
112
+ def submit_edit(
113
+ self,
114
+ identity: dict[str, object],
115
+ *,
116
+ client_task_id: str,
117
+ prompt: str,
118
+ model: str,
119
+ size: str | None,
120
+ base_url: str,
121
+ images: list[tuple[bytes, str, str]],
122
+ ) -> dict[str, Any]:
123
+ payload = {
124
+ "prompt": prompt,
125
+ "images": images,
126
+ "model": model,
127
+ "n": 1,
128
+ "size": size,
129
+ "response_format": "url",
130
+ "base_url": base_url,
131
+ }
132
+ return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
133
+
134
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
135
+ owner = _owner_id(identity)
136
+ requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
137
+ with self._lock:
138
+ if self._cleanup_locked():
139
+ self._save_locked()
140
+ items = []
141
+ missing_ids = []
142
+ for task_id in requested_ids:
143
+ task = self._tasks.get(_task_key(owner, task_id))
144
+ if task is None:
145
+ missing_ids.append(task_id)
146
+ else:
147
+ items.append(_public_task(task))
148
+ if not requested_ids:
149
+ items = [
150
+ _public_task(task)
151
+ for task in self._tasks.values()
152
+ if task.get("owner_id") == owner
153
+ ]
154
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
155
+ missing_ids = []
156
+ return {"items": items, "missing_ids": missing_ids}
157
+
158
+ def _submit(
159
+ self,
160
+ identity: dict[str, object],
161
+ *,
162
+ client_task_id: str,
163
+ mode: str,
164
+ payload: dict[str, Any],
165
+ ) -> dict[str, Any]:
166
+ task_id = _clean(client_task_id)
167
+ if not task_id:
168
+ raise ValueError("client_task_id is required")
169
+ owner = _owner_id(identity)
170
+ key = _task_key(owner, task_id)
171
+ now = _now_iso()
172
+ should_start = False
173
+ with self._lock:
174
+ cleaned = self._cleanup_locked()
175
+ task = self._tasks.get(key)
176
+ if task is not None:
177
+ if cleaned:
178
+ self._save_locked()
179
+ return _public_task(task)
180
+ task = {
181
+ "id": task_id,
182
+ "owner_id": owner,
183
+ "status": TASK_STATUS_QUEUED,
184
+ "mode": mode,
185
+ "model": _clean(payload.get("model"), "gpt-image-2"),
186
+ "size": _clean(payload.get("size")),
187
+ "created_at": now,
188
+ "updated_at": now,
189
+ }
190
+ self._tasks[key] = task
191
+ self._save_locked()
192
+ should_start = True
193
+
194
+ if should_start:
195
+ thread = threading.Thread(
196
+ target=self._run_task,
197
+ args=(key, mode, payload),
198
+ name=f"image-task-{task_id[:16]}",
199
+ daemon=True,
200
+ )
201
+ thread.start()
202
+ return _public_task(task)
203
+
204
+ def _run_task(self, key: str, mode: str, payload: dict[str, Any]) -> None:
205
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
206
+ try:
207
+ handler = self.edit_handler if mode == "edit" else self.generation_handler
208
+ result = handler(payload)
209
+ if not isinstance(result, dict):
210
+ raise RuntimeError("image task returned streaming result unexpectedly")
211
+ data = result.get("data")
212
+ if not isinstance(data, list) or not data:
213
+ message = _clean(result.get("message")) or "image task returned no image data"
214
+ raise RuntimeError(message)
215
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="")
216
+ except Exception as exc:
217
+ self._update_task(key, status=TASK_STATUS_ERROR, error=str(exc) or "image task failed", data=[])
218
+
219
+ def _update_task(self, key: str, **updates: Any) -> None:
220
+ with self._lock:
221
+ task = self._tasks.get(key)
222
+ if task is None:
223
+ return
224
+ task.update(updates)
225
+ task["updated_at"] = _now_iso()
226
+ self._save_locked()
227
+
228
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
229
+ if not self.path.exists():
230
+ return {}
231
+ try:
232
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
233
+ except Exception:
234
+ return {}
235
+ raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
236
+ if not isinstance(raw_items, list):
237
+ return {}
238
+ tasks: dict[str, dict[str, Any]] = {}
239
+ for item in raw_items:
240
+ if not isinstance(item, dict):
241
+ continue
242
+ task_id = _clean(item.get("id"))
243
+ owner = _clean(item.get("owner_id"))
244
+ if not task_id or not owner:
245
+ continue
246
+ status = _clean(item.get("status"))
247
+ if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
248
+ status = TASK_STATUS_ERROR
249
+ task = {
250
+ "id": task_id,
251
+ "owner_id": owner,
252
+ "status": status,
253
+ "mode": "edit" if item.get("mode") == "edit" else "generate",
254
+ "model": _clean(item.get("model"), "gpt-image-2"),
255
+ "size": _clean(item.get("size")),
256
+ "created_at": _clean(item.get("created_at"), _now_iso()),
257
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
258
+ }
259
+ data = item.get("data")
260
+ if isinstance(data, list):
261
+ task["data"] = data
262
+ error = _clean(item.get("error"))
263
+ if error:
264
+ task["error"] = error
265
+ tasks[_task_key(owner, task_id)] = task
266
+ return tasks
267
+
268
+ def _save_locked(self) -> None:
269
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
270
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
271
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
272
+ tmp_path.replace(self.path)
273
+
274
+ def _recover_unfinished_locked(self) -> bool:
275
+ changed = False
276
+ for task in self._tasks.values():
277
+ if task.get("status") in UNFINISHED_STATUSES:
278
+ task["status"] = TASK_STATUS_ERROR
279
+ task["error"] = "服务已重启,未完成的图片任务已中断"
280
+ task["updated_at"] = _now_iso()
281
+ changed = True
282
+ return changed
283
+
284
+ def _cleanup_locked(self) -> bool:
285
+ try:
286
+ retention_days = max(1, int(self.retention_days_getter()))
287
+ except Exception:
288
+ retention_days = 30
289
+ cutoff = time.time() - retention_days * 86400
290
+ removed_keys = [
291
+ key
292
+ for key, task in self._tasks.items()
293
+ if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
294
+ ]
295
+ for key in removed_keys:
296
+ self._tasks.pop(key, None)
297
+ return bool(removed_keys)
298
+
299
+
300
+ image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
test/test_image_task_service.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from services.image_task_service import ImageTaskService
10
+
11
+
12
+ OWNER = {"id": "owner-1", "name": "Owner", "role": "admin"}
13
+ OTHER_OWNER = {"id": "owner-2", "name": "Other", "role": "user"}
14
+
15
+
16
+ def wait_for_task(service: ImageTaskService, identity: dict[str, object], task_id: str, status: str, timeout: float = 2.0):
17
+ deadline = time.time() + timeout
18
+ last = None
19
+ while time.time() < deadline:
20
+ result = service.list_tasks(identity, [task_id])
21
+ last = (result.get("items") or [None])[0]
22
+ if last and last.get("status") == status:
23
+ return last
24
+ time.sleep(0.02)
25
+ raise AssertionError(f"task {task_id} did not reach {status}, last={last}")
26
+
27
+
28
+ class ImageTaskServiceTests(unittest.TestCase):
29
+ def make_service(self, path: Path, handler=None) -> ImageTaskService:
30
+ return ImageTaskService(
31
+ path,
32
+ generation_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/image.png"}]}),
33
+ edit_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/edit.png"}]}),
34
+ retention_days_getter=lambda: 30,
35
+ )
36
+
37
+ def test_duplicate_submit_uses_existing_task(self):
38
+ with tempfile.TemporaryDirectory() as tmp_dir:
39
+ calls = 0
40
+
41
+ def handler(_payload):
42
+ nonlocal calls
43
+ calls += 1
44
+ time.sleep(0.05)
45
+ return {"data": [{"url": "http://example.test/image.png"}]}
46
+
47
+ service = self.make_service(Path(tmp_dir) / "image_tasks.json", handler)
48
+ first = service.submit_generation(
49
+ OWNER,
50
+ client_task_id="task-1",
51
+ prompt="cat",
52
+ model="gpt-image-2",
53
+ size=None,
54
+ base_url="http://local.test",
55
+ )
56
+ second = service.submit_generation(
57
+ OWNER,
58
+ client_task_id="task-1",
59
+ prompt="cat",
60
+ model="gpt-image-2",
61
+ size=None,
62
+ base_url="http://local.test",
63
+ )
64
+
65
+ self.assertEqual(first["id"], "task-1")
66
+ self.assertEqual(second["id"], "task-1")
67
+ task = wait_for_task(service, OWNER, "task-1", "success")
68
+ self.assertEqual(task["data"][0]["url"], "http://example.test/image.png")
69
+ self.assertEqual(calls, 1)
70
+
71
+ def test_different_owner_cannot_query_task(self):
72
+ with tempfile.TemporaryDirectory() as tmp_dir:
73
+ service = self.make_service(Path(tmp_dir) / "image_tasks.json")
74
+ service.submit_generation(
75
+ OWNER,
76
+ client_task_id="private-task",
77
+ prompt="cat",
78
+ model="gpt-image-2",
79
+ size=None,
80
+ base_url="http://local.test",
81
+ )
82
+
83
+ wait_for_task(service, OWNER, "private-task", "success")
84
+ result = service.list_tasks(OTHER_OWNER, ["private-task"])
85
+
86
+ self.assertEqual(result["items"], [])
87
+ self.assertEqual(result["missing_ids"], ["private-task"])
88
+
89
+ def test_success_task_persists_to_new_service_instance(self):
90
+ with tempfile.TemporaryDirectory() as tmp_dir:
91
+ path = Path(tmp_dir) / "image_tasks.json"
92
+ service = self.make_service(path)
93
+ service.submit_generation(
94
+ OWNER,
95
+ client_task_id="persisted-task",
96
+ prompt="cat",
97
+ model="gpt-image-2",
98
+ size=None,
99
+ base_url="http://local.test",
100
+ )
101
+ wait_for_task(service, OWNER, "persisted-task", "success")
102
+
103
+ reloaded = self.make_service(path)
104
+ result = reloaded.list_tasks(OWNER, ["persisted-task"])
105
+
106
+ self.assertEqual(result["missing_ids"], [])
107
+ self.assertEqual(result["items"][0]["status"], "success")
108
+ self.assertEqual(result["items"][0]["data"][0]["url"], "http://example.test/image.png")
109
+
110
+ def test_startup_marks_unfinished_tasks_as_error(self):
111
+ with tempfile.TemporaryDirectory() as tmp_dir:
112
+ path = Path(tmp_dir) / "image_tasks.json"
113
+ path.write_text(
114
+ json.dumps(
115
+ {
116
+ "tasks": [
117
+ {
118
+ "id": "queued-task",
119
+ "owner_id": "owner-1",
120
+ "status": "queued",
121
+ "mode": "generate",
122
+ "model": "gpt-image-2",
123
+ "created_at": "2099-01-01 00:00:00",
124
+ "updated_at": "2099-01-01 00:00:00",
125
+ },
126
+ {
127
+ "id": "running-task",
128
+ "owner_id": "owner-1",
129
+ "status": "running",
130
+ "mode": "generate",
131
+ "model": "gpt-image-2",
132
+ "created_at": "2099-01-01 00:00:00",
133
+ "updated_at": "2099-01-01 00:00:00",
134
+ },
135
+ ]
136
+ }
137
+ ),
138
+ encoding="utf-8",
139
+ )
140
+
141
+ service = self.make_service(path)
142
+ result = service.list_tasks(OWNER, ["queued-task", "running-task"])
143
+
144
+ self.assertEqual([item["status"] for item in result["items"]], ["error", "error"])
145
+ self.assertTrue(all("已中断" in item.get("error", "") for item in result["items"]))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
test/test_image_tasks_api.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from unittest import mock
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+
9
+ import api.image_tasks as image_tasks_module
10
+
11
+
12
+ AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
13
+
14
+
15
+ class FakeImageTaskService:
16
+ def __init__(self):
17
+ self.generation_calls = []
18
+ self.edit_calls = []
19
+
20
+ def submit_generation(self, identity, **kwargs):
21
+ self.generation_calls.append((identity, kwargs))
22
+ return {
23
+ "id": kwargs["client_task_id"],
24
+ "status": "success",
25
+ "mode": "generate",
26
+ "created_at": "2026-01-01 00:00:00",
27
+ "updated_at": "2026-01-01 00:00:00",
28
+ "data": [{"url": f"{kwargs['base_url']}/images/fake.png"}],
29
+ }
30
+
31
+ def submit_edit(self, identity, **kwargs):
32
+ self.edit_calls.append((identity, kwargs))
33
+ return {
34
+ "id": kwargs["client_task_id"],
35
+ "status": "queued",
36
+ "mode": "edit",
37
+ "created_at": "2026-01-01 00:00:00",
38
+ "updated_at": "2026-01-01 00:00:00",
39
+ }
40
+
41
+ def list_tasks(self, _identity, ids):
42
+ return {
43
+ "items": [
44
+ {
45
+ "id": task_id,
46
+ "status": "success",
47
+ "mode": "generate",
48
+ "created_at": "2026-01-01 00:00:00",
49
+ "updated_at": "2026-01-01 00:00:00",
50
+ "data": [{"url": "http://testserver/images/fake.png"}],
51
+ }
52
+ for task_id in ids
53
+ if task_id != "missing"
54
+ ],
55
+ "missing_ids": [task_id for task_id in ids if task_id == "missing"],
56
+ }
57
+
58
+
59
+ class ImageTasksApiTests(unittest.TestCase):
60
+ def setUp(self):
61
+ self.fake_service = FakeImageTaskService()
62
+ self.service_patcher = mock.patch.object(image_tasks_module, "image_task_service", self.fake_service)
63
+ self.service_patcher.start()
64
+ self.addCleanup(self.service_patcher.stop)
65
+ app = FastAPI()
66
+ app.include_router(image_tasks_module.create_router())
67
+ self.client = TestClient(app)
68
+
69
+ def test_create_generation_task(self):
70
+ response = self.client.post(
71
+ "/api/image-tasks/generations",
72
+ headers=AUTH_HEADERS,
73
+ json={"client_task_id": "task-1", "prompt": "cat", "model": "gpt-image-2"},
74
+ )
75
+
76
+ self.assertEqual(response.status_code, 200, response.text)
77
+ payload = response.json()
78
+ self.assertEqual(payload["id"], "task-1")
79
+ self.assertEqual(payload["status"], "success")
80
+ self.assertEqual(len(self.fake_service.generation_calls), 1)
81
+
82
+ def test_create_edit_task_accepts_multiple_images(self):
83
+ response = self.client.post(
84
+ "/api/image-tasks/edits",
85
+ headers=AUTH_HEADERS,
86
+ data={"client_task_id": "edit-1", "prompt": "edit", "model": "gpt-image-2"},
87
+ files=[
88
+ ("image", ("one.png", b"one", "image/png")),
89
+ ("image", ("two.png", b"two", "image/png")),
90
+ ],
91
+ )
92
+
93
+ self.assertEqual(response.status_code, 200, response.text)
94
+ self.assertEqual(response.json()["id"], "edit-1")
95
+ self.assertEqual(len(self.fake_service.edit_calls), 1)
96
+ images = self.fake_service.edit_calls[0][1]["images"]
97
+ self.assertEqual(len(images), 2)
98
+
99
+ def test_list_tasks_reports_missing_ids(self):
100
+ response = self.client.get("/api/image-tasks?ids=task-1,missing", headers=AUTH_HEADERS)
101
+
102
+ self.assertEqual(response.status_code, 200, response.text)
103
+ payload = response.json()
104
+ self.assertEqual([item["id"] for item in payload["items"]], ["task-1"])
105
+ self.assertEqual(payload["missing_ids"], ["missing"])
106
+
107
+
108
+ if __name__ == "__main__":
109
+ unittest.main()
web/src/app/image/components/image-results.tsx CHANGED
@@ -21,6 +21,13 @@ type ImageResultsProps = {
21
  formatConversationTime: (value: string) => string;
22
  };
23
 
 
 
 
 
 
 
 
24
  export function ImageResults({
25
  selectedConversation,
26
  onOpenLightbox,
@@ -71,18 +78,19 @@ export function ImageResults({
71
  id: `${turn.id}-reference-${index}`,
72
  src: image.dataUrl,
73
  }));
74
- const successfulTurnImages = turn.images.flatMap((image) =>
75
- image.status === "success" && image.b64_json
 
76
  ? [
77
  {
78
  id: image.id,
79
- src: `data:image/png;base64,${image.b64_json}`,
80
- sizeLabel: formatBase64ImageSize(image.b64_json),
81
  dimensions: imageDimensions[image.id],
82
  },
83
  ]
84
- : [],
85
- );
86
 
87
  return (
88
  <div key={turn.id} className="flex flex-col gap-4">
@@ -145,9 +153,10 @@ export function ImageResults({
145
 
146
  <div className="columns-1 gap-4 space-y-4 sm:columns-2 xl:columns-3">
147
  {turn.images.map((image, index) => {
148
- if (image.status === "success" && image.b64_json) {
 
149
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
150
- const sizeLabel = formatBase64ImageSize(image.b64_json);
151
  const dimensions = imageDimensions[image.id];
152
  const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
153
 
@@ -162,7 +171,7 @@ export function ImageResults({
162
  className="group block w-full cursor-zoom-in"
163
  >
164
  <img
165
- src={`data:image/png;base64,${image.b64_json}`}
166
  alt={`Generated result ${index + 1}`}
167
  className="block h-auto w-full transition duration-200 group-hover:brightness-90"
168
  onLoad={(event) => {
 
21
  formatConversationTime: (value: string) => string;
22
  };
23
 
24
+ function getStoredImageSrc(image: StoredImage) {
25
+ if (image.b64_json) {
26
+ return `data:image/png;base64,${image.b64_json}`;
27
+ }
28
+ return image.url || "";
29
+ }
30
+
31
  export function ImageResults({
32
  selectedConversation,
33
  onOpenLightbox,
 
78
  id: `${turn.id}-reference-${index}`,
79
  src: image.dataUrl,
80
  }));
81
+ const successfulTurnImages = turn.images.flatMap((image) => {
82
+ const src = image.status === "success" ? getStoredImageSrc(image) : "";
83
+ return src
84
  ? [
85
  {
86
  id: image.id,
87
+ src,
88
+ sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
89
  dimensions: imageDimensions[image.id],
90
  },
91
  ]
92
+ : [];
93
+ });
94
 
95
  return (
96
  <div key={turn.id} className="flex flex-col gap-4">
 
153
 
154
  <div className="columns-1 gap-4 space-y-4 sm:columns-2 xl:columns-3">
155
  {turn.images.map((image, index) => {
156
+ const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
157
+ if (image.status === "success" && imageSrc) {
158
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
159
+ const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
160
  const dimensions = imageDimensions[image.id];
161
  const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
162
 
 
171
  className="group block w-full cursor-zoom-in"
172
  >
173
  <img
174
+ src={imageSrc}
175
  alt={`Generated result ${index + 1}`}
176
  className="block h-auto w-full transition duration-200 group-hover:brightness-90"
177
  onLoad={(event) => {
web/src/app/image/page.tsx CHANGED
@@ -17,7 +17,14 @@ import {
17
  DialogTitle,
18
  } from "@/components/ui/dialog";
19
  import { Button } from "@/components/ui/button";
20
- import { editImage, fetchAccounts, generateImage, type Account } from "@/lib/api";
 
 
 
 
 
 
 
21
  import { useAuthGuard } from "@/lib/use-auth-guard";
22
  import {
23
  clearImageConversations,
@@ -103,6 +110,81 @@ function buildReferenceImageFromResult(image: StoredImage, fileName: string): St
103
  };
104
  }
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  function pickFallbackConversationId(conversations: ImageConversation[]) {
107
  const activeConversation = conversations.find((conversation) =>
108
  conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
@@ -114,66 +196,139 @@ function sortImageConversations(conversations: ImageConversation[]) {
114
  return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
115
  }
116
 
117
- async function recoverConversationHistory(items: ImageConversation[]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  const normalized = items.map((conversation) => {
119
- let changed = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
 
 
 
 
 
 
 
 
 
121
  const turns = conversation.turns.map((turn) => {
122
  if (turn.status !== "queued" && turn.status !== "generating") {
123
  return turn;
124
  }
125
 
126
- const loadingCount = turn.images.filter((image) => image.status === "loading").length;
127
- if (loadingCount > 0) {
128
- const message = "页面刷新或任务中断,未完成的图片已标记为失败";
129
- changed = true;
 
 
130
  return {
131
- ...turn,
132
  status: "error" as const,
133
- error: message,
134
- images: turn.images.map((image) =>
135
- image.status === "loading" ? { ...image, status: "error" as const, error: message } : image,
136
- ),
137
  };
138
- }
139
-
140
- const failedCount = turn.images.filter((image) => image.status === "error").length;
141
- const successCount = turn.images.filter((image) => image.status === "success").length;
142
- const nextStatus: ImageTurnStatus =
143
- failedCount > 0 ? "error" : successCount > 0 ? "success" : "queued";
144
- const nextError = failedCount > 0 ? turn.error || `其中 ${failedCount} 张未成功生成` : undefined;
145
- if (nextStatus === turn.status && nextError === turn.error) {
146
  return turn;
147
  }
148
-
149
  changed = true;
150
  return {
151
  ...turn,
152
- status: nextStatus,
153
- error: nextError,
154
  };
155
  });
156
 
157
- if (!changed) {
158
  return conversation;
159
  }
160
 
161
- const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
162
  return {
163
  ...conversation,
164
  turns,
165
- updatedAt: lastTurn?.createdAt || conversation.updatedAt,
166
  };
167
  });
168
 
169
- const changedConversations = normalized.filter((conversation, index) => conversation !== items[index]);
170
- if (changedConversations.length > 0) {
171
  await saveImageConversations(normalized);
172
  }
173
 
174
- return normalized;
175
  }
176
 
 
177
  function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
178
  const didLoadQuotaRef = useRef(false);
179
  const conversationsRef = useRef<ImageConversation[]>([]);
@@ -490,25 +645,30 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
490
  }, []);
491
 
492
  const handleContinueEdit = useCallback(
493
- (conversationId: string, image: StoredImage | StoredReferenceImage) => {
494
- const nextReferenceImage =
495
- "dataUrl" in image
496
- ? image
497
- : buildReferenceImageFromResult(image, `conversation-${conversationId}-${Date.now()}.png`);
498
- if (!nextReferenceImage) {
499
- return;
500
- }
 
 
 
 
501
 
502
- setSelectedConversationId(conversationId);
503
- setImageMode("edit");
504
- setReferenceImages((prev) => [...prev, nextReferenceImage]);
505
- setReferenceImageFiles((prev) => [
506
- ...prev,
507
- dataUrlToFile(nextReferenceImage.dataUrl, nextReferenceImage.name, nextReferenceImage.type),
508
- ]);
509
- setImagePrompt("");
510
- textareaRef.current?.focus();
511
- toast.success("已加入当前参考图,继续输入描述即可编辑");
 
512
  },
513
  [],
514
  );
@@ -531,158 +691,116 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
531
  }
532
 
533
  const snapshot = conversationsRef.current.find((conversation) => conversation.id === conversationId);
534
- const queuedTurn = snapshot?.turns.find((turn) => turn.status === "queued");
535
- if (!snapshot || !queuedTurn) {
 
 
 
 
536
  return;
537
  }
538
 
539
  activeConversationQueueIds.add(conversationId);
540
- await updateConversation(conversationId, (current) => {
541
- const conversation = current ?? snapshot;
542
- return {
543
- ...conversation,
544
- updatedAt: new Date().toISOString(),
545
- turns: conversation.turns.map((turn) =>
546
- turn.id === queuedTurn.id
547
- ? {
548
- ...turn,
549
- status: "generating",
550
- error: undefined,
551
- }
552
- : turn,
553
- ),
554
- };
555
- });
556
-
557
- try {
558
- const referenceFiles = queuedTurn.referenceImages.map((image, index) =>
559
- dataUrlToFile(image.dataUrl, image.name || `${queuedTurn.id}-${index + 1}.png`, image.type),
560
- );
561
- const pendingImages = queuedTurn.images.filter((image) => image.status === "loading");
562
-
563
- if (queuedTurn.mode === "edit" && referenceFiles.length === 0) {
564
- throw new Error("未找到可用于继续编辑的参考图");
565
- }
566
-
567
- if (pendingImages.length === 0) {
568
- const existingFailedCount = queuedTurn.images.filter((image) => image.status === "error").length;
569
- const existingSuccessCount = queuedTurn.images.filter((image) => image.status === "success").length;
570
- await updateConversation(conversationId, (current) => {
571
- const conversation = current ?? snapshot;
572
  return {
573
- ...conversation,
574
- updatedAt: new Date().toISOString(),
575
- turns: conversation.turns.map((turn) =>
576
- turn.id === queuedTurn.id
577
- ? {
578
- ...turn,
579
- status: existingFailedCount > 0 ? "error" : existingSuccessCount > 0 ? "success" : "queued",
580
- error: existingFailedCount > 0 ? `其中 ${existingFailedCount} 张未成功生成` : undefined,
581
- }
582
- : turn,
583
- ),
584
  };
585
  });
586
- return;
587
- }
588
-
589
- const tasks = pendingImages.map(async (pendingImage) => {
590
- try {
591
- const data =
592
- queuedTurn.mode === "edit"
593
- ? await editImage(referenceFiles, queuedTurn.prompt, queuedTurn.model, queuedTurn.size)
594
- : await generateImage(queuedTurn.prompt, queuedTurn.model, queuedTurn.size);
595
- const first = data.data?.[0];
596
- if (!first?.b64_json) {
597
- throw new Error("未返回图片数据");
598
- }
599
-
600
- const nextImage: StoredImage = {
601
- id: pendingImage.id,
602
- status: "success",
603
- b64_json: first.b64_json,
604
- };
605
-
606
- await updateConversation(
607
- conversationId,
608
- (current) => {
609
- const conversation = current ?? snapshot;
610
- return {
611
- ...conversation,
612
- updatedAt: new Date().toISOString(),
613
- turns: conversation.turns.map((turn) =>
614
- turn.id === queuedTurn.id
615
- ? {
616
- ...turn,
617
- images: turn.images.map((image) => (image.id === nextImage.id ? nextImage : image)),
618
- }
619
- : turn,
620
- ),
621
- };
622
- },
623
- { persist: false },
624
- );
625
-
626
- return nextImage;
627
- } catch (error) {
628
- const message = error instanceof Error ? error.message : "生成失败";
629
- const failedImage: StoredImage = {
630
- id: pendingImage.id,
631
- status: "error",
632
- error: message,
633
- };
634
-
635
- await updateConversation(
636
- conversationId,
637
- (current) => {
638
- const conversation = current ?? snapshot;
639
- return {
640
- ...conversation,
641
- updatedAt: new Date().toISOString(),
642
- turns: conversation.turns.map((turn) =>
643
- turn.id === queuedTurn.id
644
- ? {
645
- ...turn,
646
- images: turn.images.map((image) => (image.id === failedImage.id ? failedImage : image)),
647
- }
648
- : turn,
649
- ),
650
- };
651
- },
652
- { persist: false },
653
- );
654
-
655
- throw error;
656
- }
657
  });
 
658
 
659
- const settled = await Promise.allSettled(tasks);
660
- const resumedSuccessCount = settled.filter(
661
- (item): item is PromiseFulfilledResult<StoredImage> => item.status === "fulfilled",
662
- ).length;
663
- const resumedFailedCount = settled.length - resumedSuccessCount;
664
- const existingSuccessCount = queuedTurn.images.filter((image) => image.status === "success").length;
665
- const existingFailedCount = queuedTurn.images.filter((image) => image.status === "error").length;
666
- const successCount = existingSuccessCount + resumedSuccessCount;
667
- const failedCount = existingFailedCount + resumedFailedCount;
668
-
669
  await updateConversation(conversationId, (current) => {
670
  const conversation = current ?? snapshot;
671
  return {
672
  ...conversation,
673
  updatedAt: new Date().toISOString(),
674
  turns: conversation.turns.map((turn) =>
675
- turn.id === queuedTurn.id
676
  ? {
677
  ...turn,
678
- status: failedCount > 0 ? "error" : "success",
679
- error: failedCount > 0 ? `其中 ${failedCount} 张未成功生成` : undefined,
 
 
 
680
  }
681
  : turn,
682
  ),
683
  };
684
  });
685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
  await loadQuota();
687
  } catch (error) {
688
  const message = error instanceof Error ? error.message : "生成图片失败";
@@ -692,7 +810,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
692
  ...conversation,
693
  updatedAt: new Date().toISOString(),
694
  turns: conversation.turns.map((turn) =>
695
- turn.id === queuedTurn.id
696
  ? {
697
  ...turn,
698
  status: "error",
@@ -711,7 +829,11 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
711
  for (const conversation of conversationsRef.current) {
712
  if (
713
  !activeConversationQueueIds.has(conversation.id) &&
714
- conversation.turns.some((turn) => turn.status === "queued")
 
 
 
 
715
  ) {
716
  void runConversationQueue(conversation.id);
717
  }
@@ -726,7 +848,11 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
726
  for (const conversation of conversations) {
727
  if (
728
  !activeConversationQueueIds.has(conversation.id) &&
729
- conversation.turns.some((turn) => turn.status === "queued")
 
 
 
 
730
  ) {
731
  void runConversationQueue(conversation.id);
732
  }
@@ -759,10 +885,14 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
759
  referenceImages: imageMode === "edit" ? referenceImages : [],
760
  count: parsedCount,
761
  size: imageSize,
762
- images: Array.from({ length: parsedCount }, (_, index) => ({
763
- id: `${turnId}-${index}`,
764
- status: "loading" as const,
765
- })),
 
 
 
 
766
  createdAt: now,
767
  status: "queued",
768
  };
 
17
  DialogTitle,
18
  } from "@/components/ui/dialog";
19
  import { Button } from "@/components/ui/button";
20
+ import {
21
+ createImageEditTask,
22
+ createImageGenerationTask,
23
+ fetchAccounts,
24
+ fetchImageTasks,
25
+ type Account,
26
+ type ImageTask,
27
+ } from "@/lib/api";
28
  import { useAuthGuard } from "@/lib/use-auth-guard";
29
  import {
30
  clearImageConversations,
 
110
  };
111
  }
112
 
113
+ async function fetchImageAsFile(url: string, fileName: string) {
114
+ const response = await fetch(url);
115
+ if (!response.ok) {
116
+ throw new Error("读取结果图失败");
117
+ }
118
+ const blob = await response.blob();
119
+ return new File([blob], fileName, { type: blob.type || "image/png" });
120
+ }
121
+
122
+ async function buildReferenceImageFromStoredImage(image: StoredImage, fileName: string) {
123
+ const direct = buildReferenceImageFromResult(image, fileName);
124
+ if (direct) {
125
+ return {
126
+ referenceImage: direct,
127
+ file: dataUrlToFile(direct.dataUrl, direct.name, direct.type),
128
+ };
129
+ }
130
+
131
+ if (!image.url) {
132
+ return null;
133
+ }
134
+ const file = await fetchImageAsFile(image.url, fileName);
135
+ return {
136
+ referenceImage: {
137
+ name: file.name,
138
+ type: file.type || "image/png",
139
+ dataUrl: await readFileAsDataUrl(file),
140
+ },
141
+ file,
142
+ };
143
+ }
144
+
145
+ function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
146
+ if (task.status === "success") {
147
+ const first = task.data?.[0];
148
+ if (!first?.b64_json && !first?.url) {
149
+ return {
150
+ ...image,
151
+ taskId: task.id,
152
+ status: "error",
153
+ error: "未返回图片数据",
154
+ };
155
+ }
156
+ return {
157
+ ...image,
158
+ taskId: task.id,
159
+ status: "success",
160
+ b64_json: first.b64_json,
161
+ url: first.url,
162
+ revised_prompt: first.revised_prompt,
163
+ error: undefined,
164
+ };
165
+ }
166
+
167
+ if (task.status === "error") {
168
+ return {
169
+ ...image,
170
+ taskId: task.id,
171
+ status: "error",
172
+ error: task.error || "生成失败",
173
+ };
174
+ }
175
+
176
+ return {
177
+ ...image,
178
+ taskId: task.id,
179
+ status: "loading",
180
+ error: undefined,
181
+ };
182
+ }
183
+
184
+ function sleep(ms: number) {
185
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
186
+ }
187
+
188
  function pickFallbackConversationId(conversations: ImageConversation[]) {
189
  const activeConversation = conversations.find((conversation) =>
190
  conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
 
196
  return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
197
  }
198
 
199
+ function deriveTurnStatus(turn: ImageTurn): Pick<ImageTurn, "status" | "error"> {
200
+ const loadingCount = turn.images.filter((image) => image.status === "loading").length;
201
+ const failedCount = turn.images.filter((image) => image.status === "error").length;
202
+ const successCount = turn.images.filter((image) => image.status === "success").length;
203
+ if (loadingCount > 0) {
204
+ return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
205
+ }
206
+ if (failedCount > 0) {
207
+ return { status: "error", error: `其中 ${failedCount} 张未成功生成` };
208
+ }
209
+ if (successCount > 0) {
210
+ return { status: "success", error: undefined };
211
+ }
212
+ return { status: "queued", error: undefined };
213
+ }
214
+
215
+ async function syncConversationImageTasks(items: ImageConversation[]) {
216
+ const taskIds = Array.from(
217
+ new Set(
218
+ items.flatMap((conversation) =>
219
+ conversation.turns.flatMap((turn) =>
220
+ turn.images.flatMap((image) => (image.status === "loading" && image.taskId ? [image.taskId] : [])),
221
+ ),
222
+ ),
223
+ ),
224
+ );
225
+ if (taskIds.length === 0) {
226
+ return items;
227
+ }
228
+
229
+ let taskList: Awaited<ReturnType<typeof fetchImageTasks>>;
230
+ try {
231
+ taskList = await fetchImageTasks(taskIds);
232
+ } catch {
233
+ return items;
234
+ }
235
+ const taskMap = new Map(taskList.items.map((task) => [task.id, task]));
236
+ let changed = false;
237
  const normalized = items.map((conversation) => {
238
+ const turns = conversation.turns.map((turn) => {
239
+ let turnChanged = false;
240
+ const images = turn.images.map((image) => {
241
+ if (image.status !== "loading" || !image.taskId) {
242
+ return image;
243
+ }
244
+ const task = taskMap.get(image.taskId);
245
+ if (!task) {
246
+ return image;
247
+ }
248
+ const nextImage = taskDataToStoredImage(image, task);
249
+ if (nextImage !== image) {
250
+ turnChanged = true;
251
+ }
252
+ return nextImage;
253
+ });
254
+ if (!turnChanged) {
255
+ return turn;
256
+ }
257
+ changed = true;
258
+ const derived = deriveTurnStatus({ ...turn, images });
259
+ return {
260
+ ...turn,
261
+ ...derived,
262
+ images,
263
+ };
264
+ });
265
+ if (turns === conversation.turns || !turns.some((turn, index) => turn !== conversation.turns[index])) {
266
+ return conversation;
267
+ }
268
+ return {
269
+ ...conversation,
270
+ turns,
271
+ updatedAt: new Date().toISOString(),
272
+ };
273
+ });
274
 
275
+ if (changed) {
276
+ await saveImageConversations(normalized);
277
+ }
278
+ return normalized;
279
+ }
280
+
281
+ async function recoverConversationHistory(items: ImageConversation[]) {
282
+ let changed = false;
283
+ const normalized = items.map((conversation) => {
284
  const turns = conversation.turns.map((turn) => {
285
  if (turn.status !== "queued" && turn.status !== "generating") {
286
  return turn;
287
  }
288
 
289
+ let turnChanged = false;
290
+ const images = turn.images.map((image) => {
291
+ if (image.status !== "loading" || image.taskId) {
292
+ return image;
293
+ }
294
+ turnChanged = true;
295
  return {
296
+ ...image,
297
  status: "error" as const,
298
+ error: "页面刷新或任务中断,未找到可恢复的任务 ID",
 
 
 
299
  };
300
+ });
301
+ const derived = deriveTurnStatus({ ...turn, images });
302
+ if (!turnChanged && derived.status === turn.status && derived.error === turn.error) {
 
 
 
 
 
303
  return turn;
304
  }
 
305
  changed = true;
306
  return {
307
  ...turn,
308
+ ...derived,
309
+ images,
310
  };
311
  });
312
 
313
+ if (!turns.some((turn, index) => turn !== conversation.turns[index])) {
314
  return conversation;
315
  }
316
 
 
317
  return {
318
  ...conversation,
319
  turns,
320
+ updatedAt: new Date().toISOString(),
321
  };
322
  });
323
 
324
+ if (changed) {
 
325
  await saveImageConversations(normalized);
326
  }
327
 
328
+ return syncConversationImageTasks(normalized);
329
  }
330
 
331
+
332
  function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
333
  const didLoadQuotaRef = useRef(false);
334
  const conversationsRef = useRef<ImageConversation[]>([]);
 
645
  }, []);
646
 
647
  const handleContinueEdit = useCallback(
648
+ async (conversationId: string, image: StoredImage | StoredReferenceImage) => {
649
+ try {
650
+ const nextReference =
651
+ "dataUrl" in image
652
+ ? {
653
+ referenceImage: image,
654
+ file: dataUrlToFile(image.dataUrl, image.name, image.type),
655
+ }
656
+ : await buildReferenceImageFromStoredImage(image, `conversation-${conversationId}-${Date.now()}.png`);
657
+ if (!nextReference) {
658
+ return;
659
+ }
660
 
661
+ setSelectedConversationId(conversationId);
662
+ setImageMode("edit");
663
+ setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
664
+ setReferenceImageFiles((prev) => [...prev, nextReference.file]);
665
+ setImagePrompt("");
666
+ textareaRef.current?.focus();
667
+ toast.success("已加入当前参考图,继续输入描述即可编辑");
668
+ } catch (error) {
669
+ const message = error instanceof Error ? error.message : "读取结果图失败";
670
+ toast.error(message);
671
+ }
672
  },
673
  [],
674
  );
 
691
  }
692
 
693
  const snapshot = conversationsRef.current.find((conversation) => conversation.id === conversationId);
694
+ const activeTurn = snapshot?.turns.find(
695
+ (turn) =>
696
+ (turn.status === "queued" || turn.status === "generating") &&
697
+ turn.images.some((image) => image.status === "loading"),
698
+ );
699
+ if (!snapshot || !activeTurn) {
700
  return;
701
  }
702
 
703
  activeConversationQueueIds.add(conversationId);
704
+ const applyTasks = async (tasks: ImageTask[]) => {
705
+ const taskMap = new Map(tasks.map((task) => [task.id, task]));
706
+ await updateConversation(conversationId, (current) => {
707
+ const conversation = current ?? snapshot;
708
+ const turns = conversation.turns.map((turn) => {
709
+ if (turn.id !== activeTurn.id) {
710
+ return turn;
711
+ }
712
+ const images = turn.images.map((image) => {
713
+ const taskId = image.taskId || image.id;
714
+ const task = taskMap.get(taskId);
715
+ return task ? taskDataToStoredImage({ ...image, taskId }, task) : image;
716
+ });
717
+ const derived = deriveTurnStatus({ ...turn, status: "generating", images });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  return {
719
+ ...turn,
720
+ ...derived,
721
+ images,
 
 
 
 
 
 
 
 
722
  };
723
  });
724
+ return {
725
+ ...conversation,
726
+ updatedAt: new Date().toISOString(),
727
+ turns,
728
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  });
730
+ };
731
 
732
+ try {
 
 
 
 
 
 
 
 
 
733
  await updateConversation(conversationId, (current) => {
734
  const conversation = current ?? snapshot;
735
  return {
736
  ...conversation,
737
  updatedAt: new Date().toISOString(),
738
  turns: conversation.turns.map((turn) =>
739
+ turn.id === activeTurn.id
740
  ? {
741
  ...turn,
742
+ status: "generating",
743
+ error: undefined,
744
+ images: turn.images.map((image) =>
745
+ image.status === "loading" ? { ...image, taskId: image.taskId || image.id } : image,
746
+ ),
747
  }
748
  : turn,
749
  ),
750
  };
751
  });
752
 
753
+ const referenceFiles = activeTurn.referenceImages.map((image, index) =>
754
+ dataUrlToFile(image.dataUrl, image.name || `${activeTurn.id}-${index + 1}.png`, image.type),
755
+ );
756
+ if (activeTurn.mode === "edit" && referenceFiles.length === 0) {
757
+ throw new Error("未找到可用于继续编辑的参考图");
758
+ }
759
+
760
+ const pendingImages = activeTurn.images.filter((image) => image.status === "loading");
761
+ const submitted = await Promise.all(
762
+ pendingImages.map((image) => {
763
+ const taskId = image.taskId || image.id;
764
+ return activeTurn.mode === "edit"
765
+ ? createImageEditTask(taskId, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size)
766
+ : createImageGenerationTask(taskId, activeTurn.prompt, activeTurn.model, activeTurn.size);
767
+ }),
768
+ );
769
+ await applyTasks(submitted);
770
+
771
+ while (true) {
772
+ const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
773
+ const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
774
+ const loadingTaskIds =
775
+ latestTurn?.images.flatMap((image) =>
776
+ image.status === "loading" && image.taskId ? [image.taskId] : [],
777
+ ) || [];
778
+ if (loadingTaskIds.length === 0) {
779
+ break;
780
+ }
781
+
782
+ await sleep(2000);
783
+ const taskList = await fetchImageTasks(loadingTaskIds);
784
+ if (taskList.items.length > 0) {
785
+ await applyTasks(taskList.items);
786
+ }
787
+ if (taskList.missing_ids.length > 0 && latestTurn) {
788
+ const missingImages = latestTurn.images.filter(
789
+ (image) => image.status === "loading" && image.taskId && taskList.missing_ids.includes(image.taskId),
790
+ );
791
+ const resubmitted = await Promise.all(
792
+ missingImages.map((image) =>
793
+ activeTurn.mode === "edit"
794
+ ? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size)
795
+ : createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size),
796
+ ),
797
+ );
798
+ if (resubmitted.length > 0) {
799
+ await applyTasks(resubmitted);
800
+ }
801
+ }
802
+ }
803
+
804
  await loadQuota();
805
  } catch (error) {
806
  const message = error instanceof Error ? error.message : "生成图片失败";
 
810
  ...conversation,
811
  updatedAt: new Date().toISOString(),
812
  turns: conversation.turns.map((turn) =>
813
+ turn.id === activeTurn.id
814
  ? {
815
  ...turn,
816
  status: "error",
 
829
  for (const conversation of conversationsRef.current) {
830
  if (
831
  !activeConversationQueueIds.has(conversation.id) &&
832
+ conversation.turns.some(
833
+ (turn) =>
834
+ (turn.status === "queued" || turn.status === "generating") &&
835
+ turn.images.some((image) => image.status === "loading"),
836
+ )
837
  ) {
838
  void runConversationQueue(conversation.id);
839
  }
 
848
  for (const conversation of conversations) {
849
  if (
850
  !activeConversationQueueIds.has(conversation.id) &&
851
+ conversation.turns.some(
852
+ (turn) =>
853
+ (turn.status === "queued" || turn.status === "generating") &&
854
+ turn.images.some((image) => image.status === "loading"),
855
+ )
856
  ) {
857
  void runConversationQueue(conversation.id);
858
  }
 
885
  referenceImages: imageMode === "edit" ? referenceImages : [],
886
  count: parsedCount,
887
  size: imageSize,
888
+ images: Array.from({ length: parsedCount }, (_, index) => {
889
+ const imageId = `${turnId}-${index}`;
890
+ return {
891
+ id: imageId,
892
+ taskId: imageId,
893
+ status: "loading" as const,
894
+ };
895
+ }),
896
  createdAt: now,
897
  status: "queued",
898
  };
web/src/lib/api.ts CHANGED
@@ -79,7 +79,24 @@ export type SystemLog = {
79
 
80
  export type ImageResponse = {
81
  created: number;
82
- data: Array<{ b64_json: string; revised_prompt?: string }>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  };
84
 
85
  export type LoginResponse = {
@@ -232,6 +249,54 @@ export async function editImage(files: File | File[], prompt: string, model?: Im
232
  );
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  export async function fetchSettingsConfig() {
236
  return httpRequest<{ config: SettingsConfig }>("/api/settings");
237
  }
 
79
 
80
  export type ImageResponse = {
81
  created: number;
82
+ data: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
83
+ };
84
+
85
+ export type ImageTask = {
86
+ id: string;
87
+ status: "queued" | "running" | "success" | "error";
88
+ mode: "generate" | "edit";
89
+ model?: ImageModel;
90
+ size?: string;
91
+ created_at: string;
92
+ updated_at: string;
93
+ data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
94
+ error?: string;
95
+ };
96
+
97
+ type ImageTaskListResponse = {
98
+ items: ImageTask[];
99
+ missing_ids: string[];
100
  };
101
 
102
  export type LoginResponse = {
 
249
  );
250
  }
251
 
252
+ export async function createImageGenerationTask(clientTaskId: string, prompt: string, model?: ImageModel, size?: string) {
253
+ return httpRequest<ImageTask>("/api/image-tasks/generations", {
254
+ method: "POST",
255
+ body: {
256
+ client_task_id: clientTaskId,
257
+ prompt,
258
+ ...(model ? { model } : {}),
259
+ ...(size ? { size } : {}),
260
+ },
261
+ });
262
+ }
263
+
264
+ export async function createImageEditTask(
265
+ clientTaskId: string,
266
+ files: File | File[],
267
+ prompt: string,
268
+ model?: ImageModel,
269
+ size?: string,
270
+ ) {
271
+ const formData = new FormData();
272
+ const uploadFiles = Array.isArray(files) ? files : [files];
273
+
274
+ uploadFiles.forEach((file) => {
275
+ formData.append("image", file);
276
+ });
277
+ formData.append("client_task_id", clientTaskId);
278
+ formData.append("prompt", prompt);
279
+ if (model) {
280
+ formData.append("model", model);
281
+ }
282
+ if (size) {
283
+ formData.append("size", size);
284
+ }
285
+
286
+ return httpRequest<ImageTask>("/api/image-tasks/edits", {
287
+ method: "POST",
288
+ body: formData,
289
+ });
290
+ }
291
+
292
+ export async function fetchImageTasks(ids: string[]) {
293
+ const params = new URLSearchParams();
294
+ if (ids.length > 0) {
295
+ params.set("ids", ids.join(","));
296
+ }
297
+ return httpRequest<ImageTaskListResponse>(`/api/image-tasks${params.toString() ? `?${params.toString()}` : ""}`);
298
+ }
299
+
300
  export async function fetchSettingsConfig() {
301
  return httpRequest<{ config: SettingsConfig }>("/api/settings");
302
  }
web/src/store/image-conversations.ts CHANGED
@@ -14,8 +14,11 @@ export type StoredReferenceImage = {
14
 
15
  export type StoredImage = {
16
  id: string;
 
17
  status?: "loading" | "success" | "error";
18
  b64_json?: string;
 
 
19
  error?: string;
20
  };
21
 
@@ -57,12 +60,18 @@ const IMAGE_CONVERSATIONS_KEY = "items";
57
  let imageConversationWriteQueue: Promise<void> = Promise.resolve();
58
 
59
  function normalizeStoredImage(image: StoredImage): StoredImage {
 
 
 
 
 
 
60
  if (image.status === "loading" || image.status === "error" || image.status === "success") {
61
- return image;
62
  }
63
  return {
64
- ...image,
65
- status: image.b64_json ? "success" : "loading",
66
  };
67
  }
68
 
 
14
 
15
  export type StoredImage = {
16
  id: string;
17
+ taskId?: string;
18
  status?: "loading" | "success" | "error";
19
  b64_json?: string;
20
+ url?: string;
21
+ revised_prompt?: string;
22
  error?: string;
23
  };
24
 
 
60
  let imageConversationWriteQueue: Promise<void> = Promise.resolve();
61
 
62
  function normalizeStoredImage(image: StoredImage): StoredImage {
63
+ const normalized = {
64
+ ...image,
65
+ taskId: typeof image.taskId === "string" && image.taskId ? image.taskId : undefined,
66
+ url: typeof image.url === "string" && image.url ? image.url : undefined,
67
+ revised_prompt: typeof image.revised_prompt === "string" ? image.revised_prompt : undefined,
68
+ };
69
  if (image.status === "loading" || image.status === "error" || image.status === "success") {
70
+ return normalized;
71
  }
72
  return {
73
+ ...normalized,
74
+ status: image.b64_json || image.url ? "success" : "loading",
75
  };
76
  }
77