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 +22 -0
- services/image_task_service.py +143 -3
- web/src/app/image/components/image-results.tsx +220 -69
- web/src/app/image/page.tsx +409 -102
- web/src/store/image-conversations.ts +11 -0
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(
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
const updateImageDimensions = (id: string, width: number, height: number) => {
|
| 87 |
const dimensions = formatImageDimensions(width, height);
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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:
|
| 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 =
|
| 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 |
-
<
|
| 236 |
-
|
| 237 |
-
|
| 238 |
className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 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 |
-
{
|
|
|
|
| 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 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 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 |
-
<
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
);
|
| 311 |
}
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
return (
|
| 314 |
-
<div
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 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 |
-
{
|
| 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-[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 394 |
-
}
|
| 395 |
-
|
| 396 |
-
|
|
|
|
| 397 |
}
|
| 398 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 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 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
}
|
| 525 |
};
|
|
|
|
|
|
|
|
|
|
| 526 |
|
|
|
|
|
|
|
| 527 |
void loadHistory();
|
| 528 |
return () => {
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
};
|
| 531 |
-
}, []);
|
| 532 |
|
| 533 |
useEffect(() => {
|
| 534 |
let cancelled = false;
|
|
@@ -591,11 +731,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 591 |
};
|
| 592 |
}, [isAdmin, loadQuota]);
|
| 593 |
|
| 594 |
-
|
|
|
|
| 595 |
if (!selectedConversation) {
|
| 596 |
lastConversationIdRef.current = null;
|
| 597 |
shouldStickToBottomRef.current = true;
|
| 598 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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,
|
| 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 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
</
|
| 1390 |
-
|
| 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;
|