lehuan2003 commited on
Commit
53db52e
·
1 Parent(s): db216b4

feat: 图片任务进度追踪与超时续轮询

Browse files

- 图片任务新增 progress/duration_ms/elapsed_secs/conversation_id 字段
- 新增 resume-poll API 支持超时任务继续等待
- 前端超时图片显示继续等待/忽略错误按钮
- 图片结果页 LazyImage 懒加载优化
- 优化大量图片场景滚动加载性能
- 会话切换滚动位置保存与恢复
- bfcache 页面恢复时重新同步任务状态
- 轮询连续错误容错(10次)

api/image_tasks.py CHANGED
@@ -19,6 +19,10 @@ class ImageGenerationTaskRequest(BaseModel):
19
  quality: str = "auto"
20
 
21
 
 
 
 
 
22
  def _parse_task_ids(value: str) -> list[str]:
23
  return [item.strip() for item in value.split(",") if item.strip()]
24
 
@@ -93,4 +97,22 @@ def create_router() -> APIRouter:
93
  except ValueError as exc:
94
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  return router
 
19
  quality: str = "auto"
20
 
21
 
22
+ class ResumePollRequest(BaseModel):
23
+ extra_timeout_secs: float = Field(default=30.0, ge=5.0, le=120.0)
24
+
25
+
26
  def _parse_task_ids(value: str) -> list[str]:
27
  return [item.strip() for item in value.split(",") if item.strip()]
28
 
 
97
  except ValueError as exc:
98
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
99
 
100
+ @router.post("/api/image-tasks/{task_id}/resume-poll")
101
+ async def resume_image_poll(
102
+ task_id: str,
103
+ body: ResumePollRequest,
104
+ request: Request,
105
+ authorization: str | None = Header(default=None),
106
+ ):
107
+ identity = require_identity(authorization)
108
+ try:
109
+ return await run_in_threadpool(
110
+ image_task_service.resume_poll,
111
+ identity,
112
+ task_id,
113
+ body.extra_timeout_secs,
114
+ )
115
+ except ValueError as exc:
116
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
117
+
118
  return router
services/image_task_service.py CHANGED
@@ -72,12 +72,27 @@ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
72
  "created_at": task.get("created_at"),
73
  "updated_at": task.get("updated_at"),
74
  }
 
 
75
  if task.get("data") is not None:
76
  item["data"] = task.get("data")
77
  if task.get("usage") is not None:
78
  item["usage"] = task.get("usage")
79
  if task.get("error"):
80
  item["error"] = task.get("error")
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  return item
82
 
83
 
@@ -206,6 +221,7 @@ class ImageTaskService:
206
  "quality": _clean(payload.get("quality"), "auto"),
207
  "created_at": now,
208
  "updated_at": now,
 
209
  }
210
  self._tasks[key] = task
211
  self._save_locked()
@@ -231,9 +247,16 @@ class ImageTaskService:
231
  ) -> None:
232
  started = time.time()
233
  self._update_task(key, status=TASK_STATUS_RUNNING, error="")
 
 
 
 
 
 
 
234
  try:
235
  handler = self.edit_handler if mode == "edit" else self.generation_handler
236
- result = handler(payload)
237
  if not isinstance(result, dict):
238
  raise RuntimeError("image task returned streaming result unexpectedly")
239
  data = result.get("data")
@@ -249,7 +272,8 @@ class ImageTaskService:
249
  setattr(error, "account_email", account_email)
250
  raise error
251
  usage = result.get("usage")
252
- self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="")
 
253
  self._log_call(
254
  identity,
255
  mode,
@@ -263,7 +287,11 @@ class ImageTaskService:
263
  except Exception as exc:
264
  error_message = str(exc) or "image task failed"
265
  account_email = _clean(getattr(exc, "account_email", ""))
266
- self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
 
 
 
 
267
  self._log_call(
268
  identity,
269
  mode,
@@ -323,6 +351,7 @@ class ImageTaskService:
323
  return
324
  task.update(updates)
325
  task["updated_at"] = _now_iso()
 
326
  self._save_locked()
327
 
328
  def _load_locked(self) -> dict[str, dict[str, Any]]:
@@ -356,6 +385,10 @@ class ImageTaskService:
356
  "quality": _clean(item.get("quality"), "auto"),
357
  "created_at": _clean(item.get("created_at"), _now_iso()),
358
  "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
 
 
 
 
359
  }
360
  data = item.get("data")
361
  if isinstance(data, list):
@@ -400,5 +433,112 @@ class ImageTaskService:
400
  self._tasks.pop(key, None)
401
  return bool(removed_keys)
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
  image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
 
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
 
 
221
  "quality": _clean(payload.get("quality"), "auto"),
222
  "created_at": now,
223
  "updated_at": now,
224
+ "created_ts": time.time(),
225
  }
226
  self._tasks[key] = task
227
  self._save_locked()
 
247
  ) -> None:
248
  started = time.time()
249
  self._update_task(key, status=TASK_STATUS_RUNNING, error="")
250
+ # 创建进度回调,每个步骤完成后更新任务状态
251
+ def progress_callback(step: str) -> None:
252
+ if step == "image_stream_resolve_start":
253
+ self._update_task(key, started_ts=time.time())
254
+ self._update_task(key, progress=step)
255
+ # 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
256
+ payload_with_progress = {**payload, "progress_callback": progress_callback}
257
  try:
258
  handler = self.edit_handler if mode == "edit" else self.generation_handler
259
+ result = handler(payload_with_progress)
260
  if not isinstance(result, dict):
261
  raise RuntimeError("image task returned streaming result unexpectedly")
262
  data = result.get("data")
 
272
  setattr(error, "account_email", account_email)
273
  raise error
274
  usage = result.get("usage")
275
+ duration_ms = int((time.time() - started) * 1000)
276
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms)
277
  self._log_call(
278
  identity,
279
  mode,
 
287
  except Exception as exc:
288
  error_message = str(exc) or "image task failed"
289
  account_email = _clean(getattr(exc, "account_email", ""))
290
+ conversation_id = _clean(getattr(exc, "conversation_id", ""))
291
+ duration_ms = int((time.time() - started) * 1000)
292
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
293
+ duration_ms=duration_ms,
294
+ **({"conversation_id": conversation_id} if conversation_id else {}))
295
  self._log_call(
296
  identity,
297
  mode,
 
351
  return
352
  task.update(updates)
353
  task["updated_at"] = _now_iso()
354
+ task["updated_ts"] = time.time()
355
  self._save_locked()
356
 
357
  def _load_locked(self) -> dict[str, dict[str, Any]]:
 
385
  "quality": _clean(item.get("quality"), "auto"),
386
  "created_at": _clean(item.get("created_at"), _now_iso()),
387
  "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
388
+ "created_ts": item.get("created_ts"),
389
+ "updated_ts": item.get("updated_ts"),
390
+ "started_ts": item.get("started_ts"),
391
+ "duration_ms": item.get("duration_ms"),
392
  }
393
  data = item.get("data")
394
  if isinstance(data, list):
 
433
  self._tasks.pop(key, None)
434
  return bool(removed_keys)
435
 
436
+ def resume_poll(
437
+ self,
438
+ identity: dict[str, object],
439
+ task_id: str,
440
+ extra_timeout_secs: float = 30.0,
441
+ ) -> dict[str, Any]:
442
+ """恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
443
+ owner = _owner_id(identity)
444
+ key = _task_key(owner, _clean(task_id))
445
+ with self._lock:
446
+ task = self._tasks.get(key)
447
+ if task is None:
448
+ raise ValueError("task not found")
449
+ if task.get("status") != TASK_STATUS_ERROR:
450
+ raise ValueError("task is not in error state")
451
+ error_msg = _clean(task.get("error"))
452
+ if "超时" not in error_msg:
453
+ raise ValueError("task error is not a timeout error")
454
+ conversation_id = _clean(task.get("conversation_id"))
455
+ if not conversation_id:
456
+ raise ValueError("task has no conversation_id")
457
+ mode = task.get("mode", "generate")
458
+ model = task.get("model", "gpt-image-2")
459
+ # 将任务状态重置为 running
460
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
461
+
462
+ # 启动新线程继续轮询
463
+ thread = threading.Thread(
464
+ target=self._run_resume_poll,
465
+ args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
466
+ name=f"image-resume-{_clean(task_id)[:16]}",
467
+ daemon=True,
468
+ )
469
+ thread.start()
470
+ return _public_task(task)
471
+
472
+ def _run_resume_poll(
473
+ self,
474
+ key: str,
475
+ conversation_id: str,
476
+ extra_timeout_secs: float,
477
+ identity: dict[str, object],
478
+ mode: str,
479
+ model: str,
480
+ ) -> None:
481
+ """后台线程:继续轮询已有 conversation_id 的图片结果。"""
482
+ started = time.time()
483
+ try:
484
+ from services.openai_backend_api import OpenAIBackendAPI
485
+ from services.protocol.conversation import format_image_result
486
+
487
+ backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
488
+ file_ids, sediment_ids = backend._poll_image_results(
489
+ conversation_id,
490
+ extra_timeout_secs,
491
+ )
492
+ if not file_ids and not sediment_ids:
493
+ raise RuntimeError(
494
+ f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
495
+ )
496
+
497
+ image_urls = backend.resolve_conversation_image_urls(
498
+ conversation_id, file_ids, sediment_ids, poll=False,
499
+ )
500
+ if not image_urls:
501
+ raise RuntimeError("图片 URL 解析失败")
502
+
503
+ image_items = [
504
+ {"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
505
+ for image_data in backend.download_image_bytes(image_urls)
506
+ ]
507
+ # 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
508
+ with self._lock:
509
+ task = self._tasks.get(key)
510
+ quality = _clean(task.get("quality"), "auto") if task else "auto"
511
+ size = _clean(task.get("size")) if task else None
512
+ data = format_image_result(
513
+ image_items,
514
+ "", # prompt 已不重要,结果已经拿到了
515
+ "b64_json",
516
+ "",
517
+ int(time.time()),
518
+ )["data"]
519
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
520
+ self._log_call(
521
+ identity,
522
+ mode,
523
+ model,
524
+ started,
525
+ "调用完成(续轮询)",
526
+ status="success",
527
+ urls=_collect_image_urls(data),
528
+ )
529
+ except Exception as exc:
530
+ error_message = str(exc) or "resume poll failed"
531
+ duration_ms = int((time.time() - started) * 1000)
532
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
533
+ self._log_call(
534
+ identity,
535
+ mode,
536
+ model,
537
+ started,
538
+ "调用失败(续轮询)",
539
+ status="failed",
540
+ error=error_message,
541
+ )
542
+
543
 
544
  image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
web/src/app/image/components/image-results.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
- import { useState } from "react";
4
- import { Clock3, Download, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
5
 
6
  import { Button } from "@/components/ui/button";
7
  import { cn } from "@/lib/utils";
@@ -23,12 +23,26 @@ type ImageResultsProps = {
23
  onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
24
  onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
25
  onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
 
 
26
  formatConversationTime: (value: string) => string;
27
  };
28
 
 
 
 
29
  function getStoredImageSrc(image: StoredImage) {
30
  if (image.b64_json) {
31
- return `data:image/png;base64,${image.b64_json}`;
 
 
 
 
 
 
 
 
 
32
  }
33
  return image.url || "";
34
  }
@@ -79,18 +93,31 @@ export function ImageResults({
79
  onReuseTurnConfig,
80
  onRegenerateTurn,
81
  onRetryImage,
 
 
82
  formatConversationTime,
83
  }: ImageResultsProps) {
84
- const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  const updateImageDimensions = (id: string, width: number, height: number) => {
87
  const dimensions = formatImageDimensions(width, height);
88
- setImageDimensions((current) => {
89
- if (current[id] === dimensions) {
90
- return current;
91
- }
92
- return { ...current, [id]: dimensions };
93
- });
94
  };
95
 
96
  if (!selectedConversation) {
@@ -133,7 +160,7 @@ export function ImageResults({
133
  id: image.id,
134
  src,
135
  sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
136
- dimensions: imageDimensions[image.id],
137
  },
138
  ]
139
  : [];
@@ -224,7 +251,7 @@ export function ImageResults({
224
  if (image.status === "success" && imageSrc) {
225
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
226
  const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
227
- const dimensions = imageDimensions[image.id];
228
  const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
229
 
230
  return (
@@ -232,28 +259,24 @@ export function ImageResults({
232
  key={image.id}
233
  className="break-inside-avoid"
234
  >
235
- <button
236
- type="button"
237
- onClick={() => onOpenLightbox(successfulTurnImages, currentIndex)}
238
  className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
239
- >
240
- <img
241
- src={imageSrc}
242
- alt={`Generated result ${index + 1}`}
243
- className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
244
- onLoad={(event) => {
245
- updateImageDimensions(
246
- image.id,
247
- event.currentTarget.naturalWidth,
248
- event.currentTarget.naturalHeight,
249
- );
250
- }}
251
- />
252
- </button>
253
  <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
254
  <div className="min-w-0 text-stone-500">
255
  <span>结果 {index + 1}</span>
256
- {imageMeta ? <span className="block text-stone-400 sm:ml-2 sm:inline">{imageMeta}</span> : null}
 
257
  </div>
258
  <div className="flex items-center gap-1.5">
259
  <Button
@@ -283,63 +306,111 @@ export function ImageResults({
283
  }
284
 
285
  if (image.status === "error") {
 
286
  return (
287
- <div
288
- key={image.id}
289
- className={cn(
290
- "break-inside-avoid overflow-hidden rounded-xl border border-rose-200 bg-rose-50 sm:rounded-none",
291
- "aspect-square",
292
- turn.ratio === "1:1" && "sm:aspect-square",
293
- turn.ratio === "16:9" && "sm:aspect-video",
294
- turn.ratio === "9:16" && "sm:aspect-[9/16]",
295
- turn.ratio === "4:3" && "sm:aspect-[4/3]",
296
- turn.ratio === "3:4" && "sm:aspect-[3/4]",
297
- )}
298
- >
299
  <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
 
300
  <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
301
- <button
302
- type="button"
303
- onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
304
- className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
305
- >
306
- 重新生成这一张
307
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  </div>
309
  </div>
310
  );
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
313
  return (
314
- <div
315
- key={image.id}
316
- className={cn(
317
- "break-inside-avoid overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 sm:rounded-none",
318
- turn.ratio === "1:1" && "aspect-square",
319
- turn.ratio === "16:9" && "aspect-video",
320
- turn.ratio === "9:16" && "aspect-[9/16]",
321
- turn.ratio === "4:3" && "aspect-[4/3]",
322
- turn.ratio === "3:4" && "aspect-[3/4]",
323
- )}
324
- >
325
  <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
326
  <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
327
- {turn.status === "queued" ? (
328
  <Clock3 className="size-4 sm:size-5" />
329
  ) : (
330
  <LoaderCircle className="size-4 animate-spin sm:size-5" />
331
  )}
332
  </div>
333
- <p className="text-[10px] leading-4 sm:text-sm">{turn.status === "queued" ? "排队中" : "处理中"}</p>
 
 
 
 
 
 
334
  </div>
 
 
 
335
  </div>
336
  );
337
  })}
338
  </div>
339
 
340
  {turn.status === "error" && turn.error ? (
341
- <div className="mt-4 border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
342
- {turn.error}
 
 
 
 
 
 
 
 
343
  </div>
344
  ) : null}
345
 
@@ -384,20 +455,100 @@ function getTurnStatusLabel(status: ImageTurnStatus) {
384
  return "失败";
385
  }
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  function formatBase64ImageSize(base64: string) {
 
 
388
  const normalized = base64.replace(/\s/g, "");
389
  const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
390
  const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
391
 
392
  if (bytes >= 1024 * 1024) {
393
- return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
394
- }
395
- if (bytes >= 1024) {
396
- return `${(bytes / 1024).toFixed(1)} KB`;
 
397
  }
398
- return `${bytes} B`;
 
399
  }
400
 
401
  function formatImageDimensions(width: number, height: number) {
402
  return `${width} x ${height}`;
403
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  "use client";
2
 
3
+ import { memo, useEffect, useRef, useState } from "react";
4
+ import { Clock3, Download, EyeOff, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
5
 
6
  import { Button } from "@/components/ui/button";
7
  import { cn } from "@/lib/utils";
 
23
  onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
24
  onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
25
  onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
26
+ onTimeoutRetryContinue: (taskId: string) => void | Promise<void>;
27
+ onDismissErrors: (conversationId: string, turnId: string) => void | Promise<void>;
28
  formatConversationTime: (value: string) => string;
29
  };
30
 
31
+ // Blob URL 缓存:避免 base64 超长字符串在 DOM 中,改用短小的 blob: URL
32
+ const b64BlobUrlCache = new Map<string, string>();
33
+
34
  function getStoredImageSrc(image: StoredImage) {
35
  if (image.b64_json) {
36
+ let url = b64BlobUrlCache.get(image.b64_json);
37
+ if (!url) {
38
+ const binary = atob(image.b64_json);
39
+ const bytes = new Uint8Array(binary.length);
40
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
41
+ const blob = new Blob([bytes], { type: "image/png" });
42
+ url = URL.createObjectURL(blob);
43
+ b64BlobUrlCache.set(image.b64_json, url);
44
+ }
45
+ return url;
46
  }
47
  return image.url || "";
48
  }
 
93
  onReuseTurnConfig,
94
  onRegenerateTurn,
95
  onRetryImage,
96
+ onTimeoutRetryContinue,
97
+ onDismissErrors,
98
  formatConversationTime,
99
  }: ImageResultsProps) {
100
+ const imageDimensionsRef = useRef<Record<string, string>>({});
101
+ const [currentTime, setCurrentTime] = useState(Date.now());
102
+
103
+ // 仅在存在 loading 图片时启动定时器,避免空闲时无谓重渲染
104
+ const hasLoadingImages = selectedConversation?.turns.some(
105
+ (turn) => !turn.resultsDeleted && turn.images.some((image) => image.status === "loading"),
106
+ );
107
+ useEffect(() => {
108
+ if (!hasLoadingImages) return;
109
+ const timer = setInterval(() => {
110
+ setCurrentTime(Date.now());
111
+ }, 500);
112
+ return () => clearInterval(timer);
113
+ }, [hasLoadingImages]);
114
 
115
  const updateImageDimensions = (id: string, width: number, height: number) => {
116
  const dimensions = formatImageDimensions(width, height);
117
+ // 使用 ref 存储,不触发 React 重渲染,消除级联重渲染
118
+ if (imageDimensionsRef.current[id] !== dimensions) {
119
+ imageDimensionsRef.current[id] = dimensions;
120
+ }
 
 
121
  };
122
 
123
  if (!selectedConversation) {
 
160
  id: image.id,
161
  src,
162
  sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
163
+ dimensions: imageDimensionsRef.current[image.id],
164
  },
165
  ]
166
  : [];
 
251
  if (image.status === "success" && imageSrc) {
252
  const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
253
  const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
254
+ const dimensions = imageDimensionsRef.current[image.id];
255
  const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
256
 
257
  return (
 
259
  key={image.id}
260
  className="break-inside-avoid"
261
  >
262
+ <LazyImage
263
+ src={imageSrc}
264
+ alt={`Generated result ${index + 1}`}
265
  className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
266
+ onLoad={(event) => {
267
+ updateImageDimensions(
268
+ image.id,
269
+ event.currentTarget.naturalWidth,
270
+ event.currentTarget.naturalHeight,
271
+ );
272
+ }}
273
+ onOpen={() => onOpenLightbox(successfulTurnImages, currentIndex)}
274
+ />
 
 
 
 
 
275
  <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
276
  <div className="min-w-0 text-stone-500">
277
  <span>结果 {index + 1}</span>
278
+ {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
279
+ {imageMeta ? <span className="block text-stone-400">{imageMeta}</span> : null}
280
  </div>
281
  <div className="flex items-center gap-1.5">
282
  <Button
 
306
  }
307
 
308
  if (image.status === "error") {
309
+ const isTimeoutError = image.error?.includes("超时") && image.taskId;
310
  return (
311
+ <div key={image.id} className="break-inside-avoid">
312
+ <div
313
+ className={cn(
314
+ "overflow-hidden rounded-xl border border-rose-200 bg-rose-50",
315
+ "aspect-square",
316
+ turn.ratio === "1:1" && "sm:aspect-square",
317
+ turn.ratio === "16:9" && "sm:aspect-video",
318
+ turn.ratio === "9:16" && "sm:aspect-[9/16]",
319
+ turn.ratio === "4:3" && "sm:aspect-[4/3]",
320
+ turn.ratio === "3:4" && "sm:aspect-[3/4]",
321
+ )}
322
+ >
323
  <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
324
+ <p className="font-medium">图片 {index + 1}/{turn.images.length}</p>
325
  <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
326
+ <div className="flex items-center gap-2">
327
+ {isTimeoutError && (
328
+ <button
329
+ type="button"
330
+ onClick={() => void onTimeoutRetryContinue(image.taskId!)}
331
+ className="rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-medium text-emerald-600 shadow-sm transition hover:bg-emerald-200 sm:px-3 sm:text-xs"
332
+ >
333
+ 继续等待
334
+ </button>
335
+ )}
336
+ <button
337
+ type="button"
338
+ onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
339
+ className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
340
+ >
341
+ 重新生成这一张
342
+ </button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
347
+ <div className="min-w-0 text-stone-500">
348
+ <span>结果 {index + 1}</span>
349
+ {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
350
+ <span className="block text-transparent">-</span>
351
+ </div>
352
  </div>
353
  </div>
354
  );
355
  }
356
 
357
+ const imageTaskStatus = image.taskStatus || (turn.status === "queued" ? "queued" : "running");
358
+ const imageStatusLabel = imageTaskStatus === "queued" ? "排队中" : getProgressLabel(image.progress);
359
+ const showElapsed = imageTaskStatus === "running" && image.elapsedSecs != null;
360
+ const elapsedDisplay = showElapsed
361
+ ? formatElapsed(
362
+ image.elapsedUpdatedAt != null
363
+ ? image.elapsedSecs! + (currentTime - image.elapsedUpdatedAt!) / 1000
364
+ : image.elapsedSecs!,
365
+ )
366
+ : null;
367
  return (
368
+ <div key={image.id} className="break-inside-avoid">
369
+ <div
370
+ className={cn(
371
+ "overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 relative",
372
+ turn.ratio === "1:1" && "aspect-square",
373
+ turn.ratio === "16:9" && "aspect-video",
374
+ turn.ratio === "9:16" && "aspect-[9/16]",
375
+ turn.ratio === "4:3" && "aspect-[4/3]",
376
+ turn.ratio === "3:4" && "aspect-[3/4]",
377
+ )}
378
+ >
379
  <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
380
  <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
381
+ {imageTaskStatus === "queued" ? (
382
  <Clock3 className="size-4 sm:size-5" />
383
  ) : (
384
  <LoaderCircle className="size-4 animate-spin sm:size-5" />
385
  )}
386
  </div>
387
+ <p className="text-[11px] font-medium leading-4 sm:text-sm">
388
+ 图片 {index + 1}/{turn.images.length}
389
+ </p>
390
+ <p className="text-[10px] leading-4 text-stone-400 sm:text-xs">
391
+ {imageStatusLabel}
392
+ </p>
393
+ </div>
394
  </div>
395
+ {elapsedDisplay != null && (
396
+ <div className="px-0.5 py-1 text-[10px] text-stone-400 sm:px-3 sm:py-3 sm:text-xs">{elapsedDisplay}</div>
397
+ )}
398
  </div>
399
  );
400
  })}
401
  </div>
402
 
403
  {turn.status === "error" && turn.error ? (
404
+ <div className="mt-4 flex items-center justify-between border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
405
+ <span>{turn.error}</span>
406
+ <button
407
+ type="button"
408
+ onClick={() => void onDismissErrors(selectedConversation.id, turn.id)}
409
+ className="ml-3 inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:bg-amber-200 hover:text-amber-900"
410
+ >
411
+ <EyeOff className="size-3" />
412
+ 忽略错误
413
+ </button>
414
  </div>
415
  ) : null}
416
 
 
455
  return "失败";
456
  }
457
 
458
+ const PROGRESS_LABELS: Record<string, string> = {
459
+ getting_account: "确认可用账号",
460
+ uploading: "上传图片",
461
+ bootstrapping: "预热首页",
462
+ getting_token: "获取 token",
463
+ preparing_conversation: "准备会话",
464
+ starting_generation: "启动生成",
465
+ generating: "生成中",
466
+ receiving_image: "接收图片中",
467
+ };
468
+
469
+ function getProgressLabel(progress?: string) {
470
+ if (!progress) {
471
+ return "生成中";
472
+ }
473
+ return PROGRESS_LABELS[progress] || "生成中";
474
+ }
475
+
476
+ function formatElapsed(seconds: number): string {
477
+ return `${seconds.toFixed(1)}s`;
478
+ }
479
+
480
+ function formatDuration(ms: number): string {
481
+ return `${(ms / 1000).toFixed(1)}s`;
482
+ }
483
+
484
+ const base64SizeCache = new Map<string, string>();
485
  function formatBase64ImageSize(base64: string) {
486
+ let cached = base64SizeCache.get(base64);
487
+ if (cached !== undefined) return cached;
488
  const normalized = base64.replace(/\s/g, "");
489
  const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
490
  const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
491
 
492
  if (bytes >= 1024 * 1024) {
493
+ cached = `${(bytes / 1024 / 1024).toFixed(2)} MB`;
494
+ } else if (bytes >= 1024) {
495
+ cached = `${(bytes / 1024).toFixed(1)} KB`;
496
+ } else {
497
+ cached = `${bytes} B`;
498
  }
499
+ base64SizeCache.set(base64, cached);
500
+ return cached;
501
  }
502
 
503
  function formatImageDimensions(width: number, height: number) {
504
  return `${width} x ${height}`;
505
  }
506
+
507
+ const LazyImage = memo(function LazyImage({ src, alt, className, onLoad, onOpen }: {
508
+ src: string;
509
+ alt: string;
510
+ className: string;
511
+ onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
512
+ onOpen?: () => void;
513
+ }) {
514
+ const [isVisible, setIsVisible] = useState(false);
515
+ const imgRef = useRef<HTMLDivElement>(null);
516
+
517
+ useEffect(() => {
518
+ const element = imgRef.current;
519
+ if (!element) return;
520
+
521
+ const observer = new IntersectionObserver(
522
+ ([entry]) => {
523
+ if (entry.isIntersecting) {
524
+ setIsVisible(true);
525
+ observer.disconnect();
526
+ }
527
+ },
528
+ { rootMargin: "400px" },
529
+ );
530
+ observer.observe(element);
531
+ return () => observer.disconnect();
532
+ }, []);
533
+
534
+ return (
535
+ <div ref={imgRef} className="relative">
536
+ {isVisible ? (
537
+ <button
538
+ type="button"
539
+ onClick={onOpen}
540
+ className={className}
541
+ >
542
+ <img
543
+ src={src}
544
+ alt={alt}
545
+ className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
546
+ onLoad={onLoad}
547
+ />
548
+ </button>
549
+ ) : (
550
+ <div className={`animate-pulse rounded-xl bg-stone-100 min-h-[200px] sm:min-h-[280px] ${className}`} />
551
+ )}
552
+ </div>
553
+ );
554
+ });
web/src/app/image/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
  import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
@@ -23,12 +23,14 @@ import {
23
  fetchAccounts,
24
  fetchModels,
25
  fetchImageTasks,
 
26
  type Account,
27
  type ImageModel,
28
  type Model,
29
  type ImageTask,
30
  } from "@/lib/api";
31
  import { useAuthGuard } from "@/lib/use-auth-guard";
 
32
  import {
33
  clearImageConversations,
34
  deleteImageConversation,
@@ -51,8 +53,32 @@ const IMAGE_TIER_STORAGE_KEY = "chatgpt2api:image_last_tier";
51
  const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
52
  const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
53
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
 
54
  const SCROLL_TO_LATEST_THRESHOLD = 160;
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  function clampImageCount(value: string) {
57
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
58
  }
@@ -62,6 +88,7 @@ function parseImageSize(size: string) {
62
  }
63
 
64
  const activeConversationQueueIds = new Set<string>();
 
65
 
66
  function getResultsDistanceFromBottom(element: HTMLElement) {
67
  return element.scrollHeight - element.scrollTop - element.clientHeight;
@@ -186,6 +213,8 @@ function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage
186
  ...image,
187
  taskId: task.id,
188
  status: "error",
 
 
189
  error: "未返回图片数据",
190
  };
191
  }
@@ -193,10 +222,13 @@ function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage
193
  ...image,
194
  taskId: task.id,
195
  status: "success",
 
 
196
  b64_json: first.b64_json,
197
  url: first.url,
198
  revised_prompt: first.revised_prompt,
199
  error: undefined,
 
200
  };
201
  }
202
 
@@ -205,15 +237,32 @@ function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage
205
  ...image,
206
  taskId: task.id,
207
  status: "error",
 
 
208
  error: task.error || "生成失败",
 
209
  };
210
  }
211
 
 
 
 
 
 
 
 
 
 
212
  return {
213
  ...image,
214
  taskId: task.id,
215
  status: "loading",
 
 
216
  error: undefined,
 
 
 
217
  };
218
  }
219
 
@@ -237,6 +286,11 @@ function deriveTurnStatus(turn: ImageTurn): Pick<ImageTurn, "status" | "error">
237
  const failedCount = turn.images.filter((image) => image.status === "error").length;
238
  const successCount = turn.images.filter((image) => image.status === "success").length;
239
  if (loadingCount > 0) {
 
 
 
 
 
240
  return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
241
  }
242
  if (failedCount > 0) {
@@ -245,7 +299,8 @@ function deriveTurnStatus(turn: ImageTurn): Pick<ImageTurn, "status" | "error">
245
  if (successCount > 0) {
246
  return { status: "success", error: undefined };
247
  }
248
- return { status: "queued", error: undefined };
 
249
  }
250
 
251
  async function syncConversationImageTasks(items: ImageConversation[]) {
@@ -255,7 +310,11 @@ async function syncConversationImageTasks(items: ImageConversation[]) {
255
  conversation.turns.flatMap((turn) =>
256
  turn.resultsDeleted
257
  ? []
258
- : turn.images.flatMap((image) => (image.status === "loading" && image.taskId ? [image.taskId] : [])),
 
 
 
 
259
  ),
260
  ),
261
  ),
@@ -276,7 +335,10 @@ async function syncConversationImageTasks(items: ImageConversation[]) {
276
  const turns = conversation.turns.map((turn) => {
277
  let turnChanged = false;
278
  const images = turn.images.map((image) => {
279
- if (image.status !== "loading" || !image.taskId) {
 
 
 
280
  return image;
281
  }
282
  const task = taskMap.get(image.taskId);
@@ -320,7 +382,7 @@ async function recoverConversationHistory(items: ImageConversation[]) {
320
  let changed = false;
321
  const normalized = items.map((conversation) => {
322
  const turns = conversation.turns.map((turn) => {
323
- if (turn.status !== "queued" && turn.status !== "generating") {
324
  return turn;
325
  }
326
 
@@ -370,12 +432,20 @@ async function recoverConversationHistory(items: ImageConversation[]) {
370
  function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
371
  const didLoadQuotaRef = useRef(false);
372
  const conversationsRef = useRef<ImageConversation[]>([]);
 
373
  const resultsViewportRef = useRef<HTMLDivElement>(null);
374
  const lastConversationIdRef = useRef<string | null>(null);
375
  const shouldStickToBottomRef = useRef(true);
376
  const scrollRafRef = useRef<number | null>(null);
 
377
  const textareaRef = useRef<HTMLTextAreaElement>(null);
378
  const fileInputRef = useRef<HTMLInputElement>(null);
 
 
 
 
 
 
379
 
380
  const [imagePrompt, setImagePrompt] = useState("");
381
  const [imageCount, setImageCount] = useState("3");
@@ -396,7 +466,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
396
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
397
  const [lightboxOpen, setLightboxOpen] = useState(false);
398
  const [lightboxIndex, setLightboxIndex] = useState(0);
399
- const [showScrollToLatest, setShowScrollToLatest] = useState(false);
400
  const [deleteConfirm, setDeleteConfirm] = useState<
401
  | { type: "one"; id: string }
402
  | { type: "prompt"; conversationId: string; turnId: string }
@@ -404,6 +474,11 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
404
  | { type: "all" }
405
  | null
406
  >(null);
 
 
 
 
 
407
 
408
  const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
409
  const selectedConversation = useMemo(
@@ -450,7 +525,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
450
  }
451
 
452
  shouldStickToBottomRef.current = true;
453
- setShowScrollToLatest(false);
 
454
  element.scrollTo({
455
  top: element.scrollHeight,
456
  behavior,
@@ -469,9 +545,33 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
469
  return;
470
  }
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
473
  shouldStickToBottomRef.current = !isAwayFromLatest;
474
- setShowScrollToLatest(isAwayFromLatest);
 
 
 
 
 
 
 
 
475
  });
476
  }, []);
477
 
@@ -480,55 +580,95 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
480
  if (scrollRafRef.current !== null) {
481
  window.cancelAnimationFrame(scrollRafRef.current);
482
  }
 
 
 
 
483
  };
484
  }, []);
485
 
486
- useEffect(() => {
487
- let cancelled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
- const loadHistory = async () => {
490
- try {
491
- const storedRatio = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
492
- const storedTier = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
493
- const storedQuality = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
494
- const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
495
- setImageRatio(storedRatio || "1:1");
496
- setImageTier(storedTier || "1k");
497
- setImageWidth("1024");
498
- setImageHeight("1024");
499
- setImageQuality(storedQuality || "auto");
500
- setImageCount(storedCount ? clampImageCount(storedCount) : "1");
501
-
502
- const items = await listImageConversations();
503
- const normalizedItems = await recoverConversationHistory(items);
504
- if (cancelled) {
505
- return;
506
- }
507
 
508
- conversationsRef.current = normalizedItems;
509
- setConversations(normalizedItems);
510
- const storedConversationId =
511
- typeof window !== "undefined" ? window.localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY) : null;
512
- const nextSelectedConversationId =
513
- (storedConversationId && normalizedItems.some((conversation) => conversation.id === storedConversationId)
514
- ? storedConversationId
515
- : null) ?? pickFallbackConversationId(normalizedItems);
516
- setSelectedConversationId(nextSelectedConversationId);
517
- } catch (error) {
518
- const message = error instanceof Error ? error.message : "读取会话记录失败";
519
- toast.error(message);
520
- } finally {
521
- if (!cancelled) {
522
- setIsLoadingHistory(false);
523
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  }
525
  };
 
 
 
526
 
 
 
527
  void loadHistory();
528
  return () => {
529
- cancelled = true;
 
 
 
 
 
 
 
 
 
 
 
 
530
  };
531
- }, []);
532
 
533
  useEffect(() => {
534
  let cancelled = false;
@@ -591,11 +731,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
591
  };
592
  }, [isAdmin, loadQuota]);
593
 
594
- useEffect(() => {
 
595
  if (!selectedConversation) {
596
  lastConversationIdRef.current = null;
597
  shouldStickToBottomRef.current = true;
598
- setShowScrollToLatest(false);
 
599
  return;
600
  }
601
 
@@ -605,13 +747,69 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
605
  }
606
 
607
  const didSwitchConversation = lastConversationIdRef.current !== selectedConversation.id;
608
- lastConversationIdRef.current = selectedConversation.id;
609
 
610
  if (didSwitchConversation) {
611
- requestAnimationFrame(() => scrollResultsToLatest("auto"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  return;
613
  }
614
 
 
615
  const shouldFollowLatest =
616
  shouldStickToBottomRef.current ||
617
  getResultsDistanceFromBottom(element) <= SCROLL_TO_LATEST_THRESHOLD;
@@ -621,7 +819,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
621
  return;
622
  }
623
 
624
- setShowScrollToLatest(true);
 
625
  }, [selectedConversation?.id, selectedConversation?.updatedAt, selectedConversation?.turns.length, scrollResultsToLatest]);
626
 
627
  useEffect(() => {
@@ -705,7 +904,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
705
 
706
  const handleCreateDraft = () => {
707
  shouldStickToBottomRef.current = true;
708
- setShowScrollToLatest(false);
 
709
  setSelectedConversationId(null);
710
  resetComposer();
711
  textareaRef.current?.focus();
@@ -988,7 +1188,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
988
  const task = taskMap.get(taskId);
989
  return task ? taskDataToStoredImage({ ...image, taskId }, task) : image;
990
  });
991
- const derived = deriveTurnStatus({ ...turn, status: "generating", images });
992
  return {
993
  ...turn,
994
  ...derived,
@@ -1004,25 +1204,6 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1004
  };
1005
 
1006
  try {
1007
- await updateConversation(conversationId, (current) => {
1008
- const conversation = current ?? snapshot;
1009
- return {
1010
- ...conversation,
1011
- updatedAt: new Date().toISOString(),
1012
- turns: conversation.turns.map((turn) =>
1013
- turn.id === activeTurn.id
1014
- ? {
1015
- ...turn,
1016
- status: "generating",
1017
- error: undefined,
1018
- images: turn.images.map((image) =>
1019
- image.status === "loading" ? { ...image, taskId: image.taskId || image.id } : image,
1020
- ),
1021
- }
1022
- : turn,
1023
- ),
1024
- };
1025
- });
1026
 
1027
  const referenceFiles = activeTurn.referenceImages.map((image, index) =>
1028
  dataUrlToFile(image.dataUrl, image.name || `${activeTurn.id}-${index + 1}.png`, image.type),
@@ -1042,6 +1223,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1042
  );
1043
  await applyTasks(submitted);
1044
 
 
 
1045
  while (true) {
1046
  const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
1047
  const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
@@ -1054,23 +1237,50 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1054
  }
1055
 
1056
  await sleep(2000);
1057
- const taskList = await fetchImageTasks(loadingTaskIds);
1058
- if (taskList.items.length > 0) {
1059
- await applyTasks(taskList.items);
1060
- }
1061
- if (taskList.missing_ids.length > 0 && latestTurn) {
1062
- const missingImages = latestTurn.images.filter(
1063
- (image) => image.status === "loading" && image.taskId && taskList.missing_ids.includes(image.taskId),
1064
- );
1065
- const resubmitted = await Promise.all(
1066
- missingImages.map((image) =>
1067
- activeTurn.mode === "edit"
1068
- ? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
1069
- : createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality),
1070
- ),
1071
- );
1072
- if (resubmitted.length > 0) {
1073
- await applyTasks(resubmitted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  }
1075
  }
1076
  }
@@ -1203,6 +1413,97 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1203
  [runConversationQueue],
1204
  );
1205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1206
  useEffect(() => {
1207
  for (const conversation of conversations) {
1208
  if (
@@ -1266,7 +1567,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1266
  };
1267
 
1268
  shouldStickToBottomRef.current = true;
1269
- setShowScrollToLatest(false);
 
1270
  setSelectedConversationId(conversationId);
1271
  clearComposerInputs();
1272
 
@@ -1362,7 +1664,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1362
  <div
1363
  ref={resultsViewportRef}
1364
  onScroll={handleResultsScroll}
1365
- className="hide-scrollbar h-full overscroll-contain scroll-smooth overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
 
1366
  >
1367
  <ImageResults
1368
  selectedConversation={selectedConversation}
@@ -1373,21 +1676,23 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1373
  onReuseTurnConfig={handleReuseTurnConfig}
1374
  onRegenerateTurn={handleRegenerateTurn}
1375
  onRetryImage={handleRetryImage}
 
 
1376
  formatConversationTime={formatConversationTime}
1377
  />
1378
  </div>
1379
 
1380
- {showScrollToLatest ? (
1381
- <button
1382
- type="button"
1383
- aria-label="滚动到最新消息"
1384
- title="滚动到最新消息"
1385
- onClick={() => scrollResultsToLatest("smooth")}
1386
- className="absolute bottom-4 left-1/2 z-20 inline-flex size-11 -translate-x-1/2 items-center justify-center rounded-full border border-stone-200 bg-white/95 text-stone-700 shadow-lg shadow-stone-200/60 backdrop-blur transition hover:-translate-y-0.5 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/10 dark:bg-stone-800/95 dark:text-stone-100 dark:shadow-black/40 dark:hover:bg-stone-700"
1387
- >
1388
- <ArrowDown className="size-5" />
1389
- </button>
1390
- ) : null}
1391
  </div>
1392
 
1393
  <ImageComposer
@@ -1449,6 +1754,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1449
  </DialogContent>
1450
  </Dialog>
1451
  ) : null}
 
 
1452
  </>
1453
  );
1454
  }
 
1
  "use client";
2
 
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
  import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
 
23
  fetchAccounts,
24
  fetchModels,
25
  fetchImageTasks,
26
+ resumeImagePoll,
27
  type Account,
28
  type ImageModel,
29
  type Model,
30
  type ImageTask,
31
  } from "@/lib/api";
32
  import { useAuthGuard } from "@/lib/use-auth-guard";
33
+ import { useSettingsStore } from "@/app/settings/store";
34
  import {
35
  clearImageConversations,
36
  deleteImageConversation,
 
53
  const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
54
  const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
55
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
56
+ const SCROLL_POSITIONS_STORAGE_KEY = "chatgpt2api:image_scroll_positions";
57
  const SCROLL_TO_LATEST_THRESHOLD = 160;
58
 
59
+ function loadScrollPositions(): Map<string, number> {
60
+ if (typeof window === "undefined") return new Map();
61
+ try {
62
+ const raw = window.sessionStorage.getItem(SCROLL_POSITIONS_STORAGE_KEY);
63
+ if (!raw) return new Map();
64
+ const parsed = JSON.parse(raw) as Record<string, number>;
65
+ return new Map(Object.entries(parsed));
66
+ } catch {
67
+ return new Map();
68
+ }
69
+ }
70
+
71
+ function saveScrollPositions(positions: Map<string, number>) {
72
+ if (typeof window === "undefined") return;
73
+ try {
74
+ const obj: Record<string, number> = {};
75
+ positions.forEach((value, key) => { obj[key] = value; });
76
+ window.sessionStorage.setItem(SCROLL_POSITIONS_STORAGE_KEY, JSON.stringify(obj));
77
+ } catch {
78
+ // sessionStorage may be full or unavailable
79
+ }
80
+ }
81
+
82
  function clampImageCount(value: string) {
83
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
84
  }
 
88
  }
89
 
90
  const activeConversationQueueIds = new Set<string>();
91
+ let pollAbortController: AbortController | null = null;
92
 
93
  function getResultsDistanceFromBottom(element: HTMLElement) {
94
  return element.scrollHeight - element.scrollTop - element.clientHeight;
 
213
  ...image,
214
  taskId: task.id,
215
  status: "error",
216
+ taskStatus: undefined,
217
+ progress: undefined,
218
  error: "未返回图片数据",
219
  };
220
  }
 
222
  ...image,
223
  taskId: task.id,
224
  status: "success",
225
+ taskStatus: undefined,
226
+ progress: undefined,
227
  b64_json: first.b64_json,
228
  url: first.url,
229
  revised_prompt: first.revised_prompt,
230
  error: undefined,
231
+ durationMs: task.duration_ms,
232
  };
233
  }
234
 
 
237
  ...image,
238
  taskId: task.id,
239
  status: "error",
240
+ taskStatus: undefined,
241
+ progress: undefined,
242
  error: task.error || "生成失败",
243
+ durationMs: task.duration_ms,
244
  };
245
  }
246
 
247
+ const newTaskStatus = task.status === "queued" ? "queued" : task.status === "running" ? "running" : image.taskStatus;
248
+ const shouldSetStartTime = newTaskStatus === "running" && !image.startTime;
249
+ const startTime = shouldSetStartTime ? Date.now() : image.startTime;
250
+ // elapsedSecs 仅使用后端返回的值,确保计时从 image_stream_resolve_start 开始
251
+ const elapsedSecs =
252
+ newTaskStatus === "running" && typeof task.elapsed_secs === "number"
253
+ ? task.elapsed_secs
254
+ : undefined;
255
+
256
  return {
257
  ...image,
258
  taskId: task.id,
259
  status: "loading",
260
+ taskStatus: newTaskStatus,
261
+ progress: task.progress || image.progress,
262
  error: undefined,
263
+ startTime,
264
+ elapsedSecs,
265
+ elapsedUpdatedAt: elapsedSecs != null ? Date.now() : undefined,
266
  };
267
  }
268
 
 
286
  const failedCount = turn.images.filter((image) => image.status === "error").length;
287
  const successCount = turn.images.filter((image) => image.status === "success").length;
288
  if (loadingCount > 0) {
289
+ // 如果任何图片的 taskStatus 为 running,则状态为 generating
290
+ const hasRunning = turn.images.some((image) => image.taskStatus === "running");
291
+ if (hasRunning) {
292
+ return { status: "generating", error: undefined };
293
+ }
294
  return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
295
  }
296
  if (failedCount > 0) {
 
299
  if (successCount > 0) {
300
  return { status: "success", error: undefined };
301
  }
302
+ // 所有图片都被忽略(images 为空),视为完成
303
+ return { status: "success", error: undefined };
304
  }
305
 
306
  async function syncConversationImageTasks(items: ImageConversation[]) {
 
310
  conversation.turns.flatMap((turn) =>
311
  turn.resultsDeleted
312
  ? []
313
+ : turn.images.flatMap((image) =>
314
+ (image.status === "loading" || (image.status === "error" && image.taskId))
315
+ ? [image.taskId!]
316
+ : [],
317
+ ),
318
  ),
319
  ),
320
  ),
 
335
  const turns = conversation.turns.map((turn) => {
336
  let turnChanged = false;
337
  const images = turn.images.map((image) => {
338
+ if (!image.taskId) {
339
+ return image;
340
+ }
341
+ if (image.status !== "loading" && image.status !== "error") {
342
  return image;
343
  }
344
  const task = taskMap.get(image.taskId);
 
382
  let changed = false;
383
  const normalized = items.map((conversation) => {
384
  const turns = conversation.turns.map((turn) => {
385
+ if (turn.status !== "queued" && turn.status !== "generating" && turn.status !== "error") {
386
  return turn;
387
  }
388
 
 
432
  function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
433
  const didLoadQuotaRef = useRef(false);
434
  const conversationsRef = useRef<ImageConversation[]>([]);
435
+ const loadCancelledRef = useRef(false);
436
  const resultsViewportRef = useRef<HTMLDivElement>(null);
437
  const lastConversationIdRef = useRef<string | null>(null);
438
  const shouldStickToBottomRef = useRef(true);
439
  const scrollRafRef = useRef<number | null>(null);
440
+ const scrollSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
441
  const textareaRef = useRef<HTMLTextAreaElement>(null);
442
  const fileInputRef = useRef<HTMLInputElement>(null);
443
+ const scrollPositionsRef = useRef<Map<string, number>>(loadScrollPositions());
444
+ const isRestoringScrollRef = useRef(false);
445
+ const scrollRestoreGenerationRef = useRef(0);
446
+
447
+ const config = useSettingsStore((state) => state.config);
448
+ const imageTimeoutRetrySecs = Number(config?.image_timeout_retry_secs || 30);
449
 
450
  const [imagePrompt, setImagePrompt] = useState("");
451
  const [imageCount, setImageCount] = useState("3");
 
466
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
467
  const [lightboxOpen, setLightboxOpen] = useState(false);
468
  const [lightboxIndex, setLightboxIndex] = useState(0);
469
+ const scrollToLatestBtnRef = useRef<HTMLButtonElement>(null);
470
  const [deleteConfirm, setDeleteConfirm] = useState<
471
  | { type: "one"; id: string }
472
  | { type: "prompt"; conversationId: string; turnId: string }
 
474
  | { type: "all" }
475
  | null
476
  >(null);
477
+ const [timeoutRetry, setTimeoutRetry] = useState<{
478
+ conversationId: string;
479
+ taskId: string;
480
+ taskError: string;
481
+ } | null>(null);
482
 
483
  const parsedCount = useMemo(() => Number(clampImageCount(imageCount)), [imageCount]);
484
  const selectedConversation = useMemo(
 
525
  }
526
 
527
  shouldStickToBottomRef.current = true;
528
+ const btn = scrollToLatestBtnRef.current;
529
+ if (btn) btn.style.display = "none";
530
  element.scrollTo({
531
  top: element.scrollHeight,
532
  behavior,
 
545
  return;
546
  }
547
 
548
+ // 恢复滚动位置期间不处理滚动事件
549
+ if (isRestoringScrollRef.current) {
550
+ return;
551
+ }
552
+
553
+ // 保存当前会话的滚动位置(debounce 300ms 写入 sessionStorage)
554
+ const convId = lastConversationIdRef.current;
555
+ if (convId) {
556
+ scrollPositionsRef.current.set(convId, element.scrollTop);
557
+ if (scrollSaveTimerRef.current) clearTimeout(scrollSaveTimerRef.current);
558
+ scrollSaveTimerRef.current = setTimeout(() => {
559
+ scrollSaveTimerRef.current = null;
560
+ saveScrollPositions(scrollPositionsRef.current);
561
+ }, 300);
562
+ }
563
+
564
  const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
565
  shouldStickToBottomRef.current = !isAwayFromLatest;
566
+ // 直接操作 DOM 控制按钮显隐,避免 setState 触发全组件重渲染
567
+ const btn = scrollToLatestBtnRef.current;
568
+ if (btn) {
569
+ if (isAwayFromLatest) {
570
+ btn.style.display = "";
571
+ } else {
572
+ btn.style.display = "none";
573
+ }
574
+ }
575
  });
576
  }, []);
577
 
 
580
  if (scrollRafRef.current !== null) {
581
  window.cancelAnimationFrame(scrollRafRef.current);
582
  }
583
+ if (scrollSaveTimerRef.current !== null) {
584
+ clearTimeout(scrollSaveTimerRef.current);
585
+ saveScrollPositions(scrollPositionsRef.current);
586
+ }
587
  };
588
  }, []);
589
 
590
+ const loadHistory = useCallback(async () => {
591
+ try {
592
+ const storedRatio =
593
+ typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
594
+ const storedTier =
595
+ typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
596
+ const storedQuality =
597
+ typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
598
+ const storedCount =
599
+ typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
600
+ setImageRatio(storedRatio || "1:1");
601
+ setImageTier(storedTier || "1k");
602
+ setImageWidth("1024");
603
+ setImageHeight("1024");
604
+ setImageQuality(storedQuality || "auto");
605
+ setImageCount(storedCount ? clampImageCount(storedCount) : "1");
606
 
607
+ const items = await listImageConversations();
608
+ const normalizedItems = await recoverConversationHistory(items);
609
+ if (loadCancelledRef.current) {
610
+ return;
611
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
+ conversationsRef.current = normalizedItems;
614
+ setConversations(normalizedItems);
615
+ const storedConversationId =
616
+ typeof window !== "undefined" ? window.localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY) : null;
617
+ const nextSelectedConversationId =
618
+ (storedConversationId && normalizedItems.some((conversation) => conversation.id === storedConversationId)
619
+ ? storedConversationId
620
+ : null) ?? pickFallbackConversationId(normalizedItems);
621
+ setSelectedConversationId(nextSelectedConversationId);
622
+ } catch (error) {
623
+ const message = error instanceof Error ? error.message : "读取会话记录失败";
624
+ toast.error(message);
625
+ } finally {
626
+ if (!loadCancelledRef.current) {
627
+ setIsLoadingHistory(false);
628
+ }
629
+ }
630
+ }, [
631
+ setImageRatio,
632
+ setImageTier,
633
+ setImageWidth,
634
+ setImageHeight,
635
+ setImageQuality,
636
+ setImageCount,
637
+ setConversations,
638
+ setSelectedConversationId,
639
+ setIsLoadingHistory,
640
+ ]);
641
+
642
+ // Handle bfcache (back/forward cache) — re-sync task status on page restore
643
+ useEffect(() => {
644
+ const handlePageShow = (event: PageTransitionEvent) => {
645
+ if (event.persisted) {
646
+ void loadHistory();
647
  }
648
  };
649
+ window.addEventListener("pageshow", handlePageShow);
650
+ return () => window.removeEventListener("pageshow", handlePageShow);
651
+ }, [loadHistory]);
652
 
653
+ useEffect(() => {
654
+ loadCancelledRef.current = false;
655
  void loadHistory();
656
  return () => {
657
+ loadCancelledRef.current = true;
658
+ // 组件卸载时保存当前滚动位置到 sessionStorage
659
+ const element = resultsViewportRef.current;
660
+ const convId = lastConversationIdRef.current;
661
+ if (element && convId) {
662
+ scrollPositionsRef.current.set(convId, element.scrollTop);
663
+ saveScrollPositions(scrollPositionsRef.current);
664
+ }
665
+ activeConversationQueueIds.clear();
666
+ if (pollAbortController) {
667
+ pollAbortController.abort();
668
+ pollAbortController = null;
669
+ }
670
  };
671
+ }, [loadHistory]);
672
 
673
  useEffect(() => {
674
  let cancelled = false;
 
731
  };
732
  }, [isAdmin, loadQuota]);
733
 
734
+ // 切换会话时保存旧会话滚动位置,并隐藏容器防止闪烁
735
+ useLayoutEffect(() => {
736
  if (!selectedConversation) {
737
  lastConversationIdRef.current = null;
738
  shouldStickToBottomRef.current = true;
739
+ const btn = scrollToLatestBtnRef.current;
740
+ if (btn) btn.style.display = "none";
741
  return;
742
  }
743
 
 
747
  }
748
 
749
  const didSwitchConversation = lastConversationIdRef.current !== selectedConversation.id;
 
750
 
751
  if (didSwitchConversation) {
752
+ // 递增 generation,使之前未完成的 rAF 回调失效
753
+ scrollRestoreGenerationRef.current += 1;
754
+
755
+ // 先保存旧会话的滚动位置(lastConversationIdRef 还是旧值)
756
+ const oldConvId = lastConversationIdRef.current;
757
+ if (oldConvId) {
758
+ scrollPositionsRef.current.set(oldConvId, element.scrollTop);
759
+ saveScrollPositions(scrollPositionsRef.current);
760
+ }
761
+ // 更新为新会话 ID
762
+ lastConversationIdRef.current = selectedConversation.id;
763
+
764
+ // 如果有保存的滚动位置,隐藏容器防止用户看到 scrollTop=0 的内容
765
+ const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
766
+ if (savedScrollTop != null && savedScrollTop > 0) {
767
+ element.style.visibility = "hidden";
768
+ isRestoringScrollRef.current = true;
769
+ }
770
+ }
771
+ }, [selectedConversation?.id]);
772
+
773
+ // 恢复滚动位置或跟随最新内容
774
+ useEffect(() => {
775
+ if (!selectedConversation) {
776
+ return;
777
+ }
778
+
779
+ const element = resultsViewportRef.current;
780
+ if (!element) {
781
+ return;
782
+ }
783
+
784
+ const savedScrollTop = scrollPositionsRef.current.get(selectedConversation.id);
785
+
786
+ if (savedScrollTop != null && savedScrollTop > 0) {
787
+ // 捕获当前 generation,用于检测是否已被新的切换取代
788
+ const generation = scrollRestoreGenerationRef.current;
789
+ // 容器已在 useLayoutEffect 中设为 visibility:hidden,用户看不到滚动过程
790
+ requestAnimationFrame(() => {
791
+ // 如果 generation 已变,说明用户又切换了,放弃本次恢复
792
+ if (scrollRestoreGenerationRef.current !== generation) return;
793
+ element.scrollTop = savedScrollTop;
794
+ // 再等一帧确保 scrollTop 生效后再显示容器
795
+ requestAnimationFrame(() => {
796
+ // 再次检查 generation
797
+ if (scrollRestoreGenerationRef.current !== generation) return;
798
+ const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
799
+ shouldStickToBottomRef.current = !isAwayFromLatest;
800
+ const btn = scrollToLatestBtnRef.current;
801
+ if (btn) btn.style.display = isAwayFromLatest ? "" : "none";
802
+ // 显示容器 — 用户直接看到正确位置的内容
803
+ element.style.visibility = "";
804
+ isRestoringScrollRef.current = false;
805
+ });
806
+ });
807
+ // 恢复后清除保存的位置,下次内容更新时走正常的 shouldFollowLatest 逻辑
808
+ scrollPositionsRef.current.delete(selectedConversation.id);
809
  return;
810
  }
811
 
812
+ // 无保存位置,按正常逻辑处理
813
  const shouldFollowLatest =
814
  shouldStickToBottomRef.current ||
815
  getResultsDistanceFromBottom(element) <= SCROLL_TO_LATEST_THRESHOLD;
 
819
  return;
820
  }
821
 
822
+ const btn = scrollToLatestBtnRef.current;
823
+ if (btn) btn.style.display = "";
824
  }, [selectedConversation?.id, selectedConversation?.updatedAt, selectedConversation?.turns.length, scrollResultsToLatest]);
825
 
826
  useEffect(() => {
 
904
 
905
  const handleCreateDraft = () => {
906
  shouldStickToBottomRef.current = true;
907
+ const btn = scrollToLatestBtnRef.current;
908
+ if (btn) btn.style.display = "none";
909
  setSelectedConversationId(null);
910
  resetComposer();
911
  textareaRef.current?.focus();
 
1188
  const task = taskMap.get(taskId);
1189
  return task ? taskDataToStoredImage({ ...image, taskId }, task) : image;
1190
  });
1191
+ const derived = deriveTurnStatus({ ...turn, images });
1192
  return {
1193
  ...turn,
1194
  ...derived,
 
1204
  };
1205
 
1206
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
 
1208
  const referenceFiles = activeTurn.referenceImages.map((image, index) =>
1209
  dataUrlToFile(image.dataUrl, image.name || `${activeTurn.id}-${index + 1}.png`, image.type),
 
1223
  );
1224
  await applyTasks(submitted);
1225
 
1226
+ let consecutiveErrors = 0;
1227
+ const retryingTaskIdsRef = new Set<string>();
1228
  while (true) {
1229
  const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
1230
  const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
 
1237
  }
1238
 
1239
  await sleep(2000);
1240
+ try {
1241
+ const taskList = await fetchImageTasks(loadingTaskIds);
1242
+ consecutiveErrors = 0;
1243
+ if (taskList.items.length > 0) {
1244
+ // 检测是否有超时错误且需要显示重试按钮
1245
+ const timeoutTask = taskList.items.find(
1246
+ (task) =>
1247
+ task.status === "error" &&
1248
+ task.error?.includes("超时") &&
1249
+ task.conversation_id &&
1250
+ !retryingTaskIdsRef.has(task.id),
1251
+ );
1252
+ if (timeoutTask && timeoutTask.conversation_id) {
1253
+ retryingTaskIdsRef.add(timeoutTask.id);
1254
+ setTimeoutRetry({
1255
+ conversationId: timeoutTask.conversation_id,
1256
+ taskId: timeoutTask.id,
1257
+ taskError: timeoutTask.error || "生图超时",
1258
+ });
1259
+ // 应用超时错误到对应图片,显示继续等待按钮
1260
+ await applyTasks([timeoutTask]);
1261
+ } else {
1262
+ await applyTasks(taskList.items);
1263
+ }
1264
+ }
1265
+ if (taskList.missing_ids.length > 0 && latestTurn) {
1266
+ const missingImages = latestTurn.images.filter(
1267
+ (image) => image.status === "loading" && image.taskId && taskList.missing_ids.includes(image.taskId),
1268
+ );
1269
+ const resubmitted = await Promise.all(
1270
+ missingImages.map((image) =>
1271
+ activeTurn.mode === "edit"
1272
+ ? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
1273
+ : createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality),
1274
+ ),
1275
+ );
1276
+ if (resubmitted.length > 0) {
1277
+ await applyTasks(resubmitted);
1278
+ }
1279
+ }
1280
+ } catch (pollError) {
1281
+ consecutiveErrors += 1;
1282
+ if (consecutiveErrors >= 10) {
1283
+ throw pollError;
1284
  }
1285
  }
1286
  }
 
1413
  [runConversationQueue],
1414
  );
1415
 
1416
+ const handleTimeoutRetryContinue = useCallback(async () => {
1417
+ if (!timeoutRetry) return;
1418
+ const { conversationId, taskId } = timeoutRetry;
1419
+ try {
1420
+ await resumeImagePoll(taskId, imageTimeoutRetrySecs);
1421
+ // 将对应图片的状态重置为 loading,并清除错误
1422
+ void updateConversation(conversationId, (current) => {
1423
+ const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
1424
+ if (!conversation) return current!;
1425
+ return {
1426
+ ...conversation,
1427
+ updatedAt: new Date().toISOString(),
1428
+ turns: conversation.turns.map((turn) => {
1429
+ const hasLoading = turn.images.some((image) => image.taskId === taskId);
1430
+ if (!hasLoading) return turn;
1431
+ return {
1432
+ ...turn,
1433
+ status: "generating" as const,
1434
+ error: undefined,
1435
+ images: turn.images.map((image) =>
1436
+ image.taskId === taskId
1437
+ ? { ...image, status: "loading" as const, error: undefined, taskStatus: "running" as const, startTime: image.startTime || Date.now() }
1438
+ : image
1439
+ ),
1440
+ };
1441
+ }),
1442
+ };
1443
+ });
1444
+ // 清除重试状态
1445
+ setTimeoutRetry(null);
1446
+ toast.info(`已继续等待 ${imageTimeoutRetrySecs} 秒`);
1447
+ } catch (err) {
1448
+ const msg = err instanceof Error ? err.message : "续轮询失败";
1449
+ toast.error(msg);
1450
+ setTimeoutRetry(null);
1451
+ }
1452
+ }, [timeoutRetry, updateConversation, imageTimeoutRetrySecs]);
1453
+
1454
+ const handleTimeoutRetryCancel = useCallback(() => {
1455
+ if (!timeoutRetry) return;
1456
+ const { conversationId: convId, taskId, taskError } = timeoutRetry;
1457
+ // 将超时错误应用到对应图片
1458
+ void updateConversation(convId, (current) => {
1459
+ const conversation = current ?? conversationsRef.current.find((c) => c.id === convId);
1460
+ if (!conversation) return current!;
1461
+ return {
1462
+ ...conversation,
1463
+ updatedAt: new Date().toISOString(),
1464
+ turns: conversation.turns.map((turn) => {
1465
+ const hasLoading = turn.images.some((image) => image.status === "loading" && image.taskId === taskId);
1466
+ if (!hasLoading) return turn;
1467
+ return {
1468
+ ...turn,
1469
+ status: "error" as const,
1470
+ error: taskError,
1471
+ images: turn.images.map((image) =>
1472
+ image.taskId === taskId ? { ...image, status: "error" as const, error: taskError } : image,
1473
+ ),
1474
+ };
1475
+ }),
1476
+ };
1477
+ });
1478
+ setTimeoutRetry(null);
1479
+ toast.error(taskError);
1480
+ }, [timeoutRetry, updateConversation]);
1481
+
1482
+ const handleDismissErrors = useCallback(
1483
+ async (conversationId: string, turnId: string) => {
1484
+ await updateConversation(conversationId, (current) => {
1485
+ const conversation = current ?? conversationsRef.current.find((c) => c.id === conversationId);
1486
+ if (!conversation) return current!;
1487
+ return {
1488
+ ...conversation,
1489
+ updatedAt: new Date().toISOString(),
1490
+ turns: conversation.turns.map((turn) => {
1491
+ if (turn.id !== turnId) return turn;
1492
+ const successImages = turn.images.filter((image) => image.status !== "error");
1493
+ const derived = deriveTurnStatus({ ...turn, images: successImages });
1494
+ return {
1495
+ ...turn,
1496
+ ...derived,
1497
+ count: successImages.length,
1498
+ images: successImages,
1499
+ };
1500
+ }),
1501
+ };
1502
+ });
1503
+ },
1504
+ [updateConversation],
1505
+ );
1506
+
1507
  useEffect(() => {
1508
  for (const conversation of conversations) {
1509
  if (
 
1567
  };
1568
 
1569
  shouldStickToBottomRef.current = true;
1570
+ const btn = scrollToLatestBtnRef.current;
1571
+ if (btn) btn.style.display = "none";
1572
  setSelectedConversationId(conversationId);
1573
  clearComposerInputs();
1574
 
 
1664
  <div
1665
  ref={resultsViewportRef}
1666
  onScroll={handleResultsScroll}
1667
+ className="hide-scrollbar h-full overscroll-contain overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
1668
+ style={{ contain: "layout style paint" }}
1669
  >
1670
  <ImageResults
1671
  selectedConversation={selectedConversation}
 
1676
  onReuseTurnConfig={handleReuseTurnConfig}
1677
  onRegenerateTurn={handleRegenerateTurn}
1678
  onRetryImage={handleRetryImage}
1679
+ onTimeoutRetryContinue={handleTimeoutRetryContinue}
1680
+ onDismissErrors={handleDismissErrors}
1681
  formatConversationTime={formatConversationTime}
1682
  />
1683
  </div>
1684
 
1685
+ <button
1686
+ ref={scrollToLatestBtnRef}
1687
+ type="button"
1688
+ aria-label="滚动到最新消息"
1689
+ title="滚动到最新消息"
1690
+ onClick={() => scrollResultsToLatest("smooth")}
1691
+ className="absolute bottom-4 left-1/2 z-20 inline-flex size-11 -translate-x-1/2 items-center justify-center rounded-full border border-stone-200 bg-white/95 text-stone-700 shadow-lg shadow-stone-200/60 backdrop-blur transition hover:-translate-y-0.5 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/10 dark:bg-stone-800/95 dark:text-stone-100 dark:shadow-black/40 dark:hover:bg-stone-700"
1692
+ style={{ display: "none" }}
1693
+ >
1694
+ <ArrowDown className="size-5" />
1695
+ </button>
1696
  </div>
1697
 
1698
  <ImageComposer
 
1754
  </DialogContent>
1755
  </Dialog>
1756
  ) : null}
1757
+
1758
+
1759
  </>
1760
  );
1761
  }
web/src/store/image-conversations.ts CHANGED
@@ -16,10 +16,16 @@ 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
 
25
  export type ImageTurnStatus = "queued" | "generating" | "success" | "error";
@@ -68,8 +74,13 @@ function normalizeStoredImage(image: StoredImage): StoredImage {
68
  const normalized = {
69
  ...image,
70
  taskId: typeof image.taskId === "string" && image.taskId ? image.taskId : undefined,
 
71
  url: typeof image.url === "string" && image.url ? image.url : undefined,
72
  revised_prompt: typeof image.revised_prompt === "string" ? image.revised_prompt : undefined,
 
 
 
 
73
  };
74
  if (image.status === "loading" || image.status === "error" || image.status === "success") {
75
  return normalized;
 
16
  id: string;
17
  taskId?: string;
18
  status?: "loading" | "success" | "error";
19
+ taskStatus?: "queued" | "running";
20
+ progress?: string;
21
  b64_json?: string;
22
  url?: string;
23
  revised_prompt?: string;
24
  error?: string;
25
+ startTime?: number;
26
+ elapsedSecs?: number;
27
+ elapsedUpdatedAt?: number;
28
+ durationMs?: number;
29
  };
30
 
31
  export type ImageTurnStatus = "queued" | "generating" | "success" | "error";
 
74
  const normalized = {
75
  ...image,
76
  taskId: typeof image.taskId === "string" && image.taskId ? image.taskId : undefined,
77
+ taskStatus: image.taskStatus === "queued" || image.taskStatus === "running" ? image.taskStatus : undefined,
78
  url: typeof image.url === "string" && image.url ? image.url : undefined,
79
  revised_prompt: typeof image.revised_prompt === "string" ? image.revised_prompt : undefined,
80
+ startTime: typeof image.startTime === "number" ? image.startTime : undefined,
81
+ elapsedSecs: typeof image.elapsedSecs === "number" ? image.elapsedSecs : undefined,
82
+ elapsedUpdatedAt: typeof image.elapsedUpdatedAt === "number" ? image.elapsedUpdatedAt : undefined,
83
+ durationMs: typeof image.durationMs === "number" ? image.durationMs : undefined,
84
  };
85
  if (image.status === "loading" || image.status === "error" || image.status === "success") {
86
  return normalized;