xiaolongmr commited on
Commit
bbc8fdb
·
1 Parent(s): dbfb96e

Improve image generation and auth responsiveness

Browse files
services/account_service.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import base64
4
  import json
 
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from datetime import datetime, timedelta, timezone
7
  from threading import Condition, Lock
@@ -17,6 +18,7 @@ from utils.helper import anonymize_token
17
 
18
 
19
  EXPORT_TIMEZONE = timezone(timedelta(hours=8))
 
20
 
21
 
22
  def _clean_string(value: Any) -> str:
@@ -142,11 +144,23 @@ class AccountService:
142
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
143
  normalized["default_model_slug"] = normalized.get("default_model_slug") or None
144
  normalized["restore_at"] = normalized.get("restore_at") or None
 
 
 
 
145
  normalized["success"] = int(normalized.get("success") or 0)
146
  normalized["fail"] = int(normalized.get("fail") or 0)
147
  normalized["last_used_at"] = normalized.get("last_used_at")
148
  return normalized
149
 
 
 
 
 
 
 
 
 
150
  def list_tokens(self) -> list[str]:
151
  with self._lock:
152
  return list(self._accounts)
@@ -198,6 +212,10 @@ class AccountService:
198
  while True:
199
  access_token = self._acquire_next_candidate_token(excluded_tokens=attempted_tokens)
200
  attempted_tokens.add(access_token)
 
 
 
 
201
  try:
202
  account = self.fetch_remote_info(access_token, "get_available_access_token")
203
  except Exception:
@@ -427,6 +445,7 @@ class AccountService:
427
  except InvalidAccessTokenError:
428
  self.remove_invalid_token(access_token, event)
429
  raise
 
430
  return self.update_account(access_token, result)
431
 
432
  def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
@@ -458,7 +477,7 @@ class AccountService:
458
  errors.append({"token": anonymize_token(token), "error": str(exc)})
459
  continue
460
  if isinstance(result, dict):
461
- refreshed_items.append({"access_token": token, **result})
462
 
463
  with self._lock:
464
  changed = False
 
2
 
3
  import base64
4
  import json
5
+ import time
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
  from datetime import datetime, timedelta, timezone
8
  from threading import Condition, Lock
 
18
 
19
 
20
  EXPORT_TIMEZONE = timezone(timedelta(hours=8))
21
+ IMAGE_ACCOUNT_REMOTE_CHECK_CACHE_SECONDS = 10 * 60
22
 
23
 
24
  def _clean_string(value: Any) -> str:
 
144
  normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
145
  normalized["default_model_slug"] = normalized.get("default_model_slug") or None
146
  normalized["restore_at"] = normalized.get("restore_at") or None
147
+ try:
148
+ normalized["image_checked_at"] = float(normalized.get("image_checked_at") or 0)
149
+ except (TypeError, ValueError):
150
+ normalized["image_checked_at"] = 0.0
151
  normalized["success"] = int(normalized.get("success") or 0)
152
  normalized["fail"] = int(normalized.get("fail") or 0)
153
  normalized["last_used_at"] = normalized.get("last_used_at")
154
  return normalized
155
 
156
+ @staticmethod
157
+ def _is_image_account_check_fresh(account: dict) -> bool:
158
+ try:
159
+ checked_at = float(account.get("image_checked_at") or 0)
160
+ except (TypeError, ValueError):
161
+ return False
162
+ return checked_at > 0 and time.time() - checked_at < IMAGE_ACCOUNT_REMOTE_CHECK_CACHE_SECONDS
163
+
164
  def list_tokens(self) -> list[str]:
165
  with self._lock:
166
  return list(self._accounts)
 
212
  while True:
213
  access_token = self._acquire_next_candidate_token(excluded_tokens=attempted_tokens)
214
  attempted_tokens.add(access_token)
215
+ with self._lock:
216
+ cached_account = dict(self._accounts.get(access_token) or {})
217
+ if self._is_image_account_available(cached_account) and self._is_image_account_check_fresh(cached_account):
218
+ return access_token
219
  try:
220
  account = self.fetch_remote_info(access_token, "get_available_access_token")
221
  except Exception:
 
445
  except InvalidAccessTokenError:
446
  self.remove_invalid_token(access_token, event)
447
  raise
448
+ result["image_checked_at"] = time.time()
449
  return self.update_account(access_token, result)
450
 
451
  def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
 
477
  errors.append({"token": anonymize_token(token), "error": str(exc)})
478
  continue
479
  if isinstance(result, dict):
480
+ refreshed_items.append({"access_token": token, **result, "image_checked_at": time.time()})
481
 
482
  with self._lock:
483
  changed = False
services/image_service.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import io
 
4
  import zipfile
5
  from pathlib import Path
6
 
@@ -15,6 +16,8 @@ from services.image_url_service import proxied_image_url
15
 
16
  THUMBNAIL_SIZE = (320, 320)
17
  IMAGE_CACHE_CONTROL = "public, max-age=2592000, immutable"
 
 
18
 
19
 
20
  def _image_cache_headers() -> dict[str, str]:
@@ -130,9 +133,19 @@ def cleanup_image_thumbnails() -> int:
130
  _cleanup_empty_dirs(thumbnails_root)
131
  return removed
132
 
133
- def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
 
 
 
 
 
 
134
  config.cleanup_old_images()
135
  cleanup_image_thumbnails()
 
 
 
 
136
  all_tags = load_tags()
137
  items = [
138
  {
 
1
  from __future__ import annotations
2
 
3
  import io
4
+ import time
5
  import zipfile
6
  from pathlib import Path
7
 
 
16
 
17
  THUMBNAIL_SIZE = (320, 320)
18
  IMAGE_CACHE_CONTROL = "public, max-age=2592000, immutable"
19
+ IMAGE_MANAGER_CLEANUP_INTERVAL_SECONDS = 60 * 60
20
+ _last_image_manager_cleanup_at = time.monotonic()
21
 
22
 
23
  def _image_cache_headers() -> dict[str, str]:
 
133
  _cleanup_empty_dirs(thumbnails_root)
134
  return removed
135
 
136
+
137
+ def _cleanup_image_manager_if_due() -> None:
138
+ global _last_image_manager_cleanup_at
139
+ now = time.monotonic()
140
+ if now - _last_image_manager_cleanup_at < IMAGE_MANAGER_CLEANUP_INTERVAL_SECONDS:
141
+ return
142
+ _last_image_manager_cleanup_at = now
143
  config.cleanup_old_images()
144
  cleanup_image_thumbnails()
145
+
146
+
147
+ def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
148
+ _cleanup_image_manager_if_due()
149
  all_tags = load_tags()
150
  items = [
151
  {
services/image_storage_service.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import hashlib
4
  import io
5
  import json
 
6
  from dataclasses import dataclass
7
  from pathlib import Path
8
  from threading import Lock
@@ -19,6 +20,7 @@ from services.time_utils import china_date_from_timestamp_string, china_from_tim
19
  IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
20
  IMAGE_INDEX_LOCK = Lock()
21
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
 
22
 
23
 
24
  class ImageStorageError(RuntimeError):
@@ -168,6 +170,7 @@ class ImageStorageService:
168
  def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
169
  self.index_file = index_file
170
  self._index_lock = IMAGE_INDEX_LOCK
 
171
 
172
  def settings(self) -> dict[str, object]:
173
  return config.get_image_storage_settings()
@@ -203,8 +206,19 @@ class ImageStorageService:
203
  relative_dir = Path(now.strftime("%Y"), now.strftime("%m"), now.strftime("%d"))
204
  return f"{relative_dir.as_posix()}/{filename}"
205
 
 
 
 
 
 
 
 
 
 
 
 
206
  def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
207
- config.cleanup_old_images()
208
  rel = self.make_relative_path(image_data)
209
  mode = self.mode()
210
  if mode not in {"local", "webdav", "both"}:
 
3
  import hashlib
4
  import io
5
  import json
6
+ import time
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
  from threading import Lock
 
20
  IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
21
  IMAGE_INDEX_LOCK = Lock()
22
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
23
+ IMAGE_SAVE_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60
24
 
25
 
26
  class ImageStorageError(RuntimeError):
 
170
  def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
171
  self.index_file = index_file
172
  self._index_lock = IMAGE_INDEX_LOCK
173
+ self._last_cleanup_at = time.monotonic()
174
 
175
  def settings(self) -> dict[str, object]:
176
  return config.get_image_storage_settings()
 
206
  relative_dir = Path(now.strftime("%Y"), now.strftime("%m"), now.strftime("%d"))
207
  return f"{relative_dir.as_posix()}/{filename}"
208
 
209
+ def _cleanup_old_images_if_due(self) -> int:
210
+ now = time.monotonic()
211
+ if now - self._last_cleanup_at < IMAGE_SAVE_CLEANUP_INTERVAL_SECONDS:
212
+ return 0
213
+ self._last_cleanup_at = now
214
+ try:
215
+ return int(config.cleanup_old_images() or 0)
216
+ except Exception as exc:
217
+ print(f"[image-storage] cleanup skipped: {exc}")
218
+ return 0
219
+
220
  def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
221
+ self._cleanup_old_images_if_due()
222
  rel = self.make_relative_path(image_data)
223
  mode = self.mode()
224
  if mode not in {"local", "webdav", "both"}:
services/image_task_service.py CHANGED
@@ -20,6 +20,7 @@ TASK_STATUS_SUCCESS = "success"
20
  TASK_STATUS_ERROR = "error"
21
  TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
22
  UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
 
23
 
24
 
25
  def _now_iso() -> str:
@@ -95,6 +96,7 @@ class ImageTaskService:
95
  self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
96
  self._lock = threading.RLock()
97
  self._tasks: dict[str, dict[str, Any]] = {}
 
98
  self.path.parent.mkdir(parents=True, exist_ok=True)
99
  with self._lock:
100
  self._tasks = self._load_locked()
@@ -167,7 +169,7 @@ class ImageTaskService:
167
  owner = _owner_id(identity)
168
  requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
169
  with self._lock:
170
- if self._cleanup_locked():
171
  self._save_locked()
172
  items = []
173
  missing_ids = []
@@ -205,7 +207,7 @@ class ImageTaskService:
205
  now = _now_iso()
206
  should_start = False
207
  with self._lock:
208
- cleaned = self._cleanup_locked()
209
  task = self._tasks.get(key)
210
  if task is not None:
211
  if cleaned:
@@ -502,5 +504,12 @@ class ImageTaskService:
502
  self._tasks.pop(key, None)
503
  return bool(removed_keys)
504
 
 
 
 
 
 
 
 
505
 
506
  image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
 
20
  TASK_STATUS_ERROR = "error"
21
  TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
22
  UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
23
+ TASK_CLEANUP_INTERVAL_SECONDS = 60 * 60
24
 
25
 
26
  def _now_iso() -> str:
 
96
  self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
97
  self._lock = threading.RLock()
98
  self._tasks: dict[str, dict[str, Any]] = {}
99
+ self._last_cleanup_at = time.monotonic()
100
  self.path.parent.mkdir(parents=True, exist_ok=True)
101
  with self._lock:
102
  self._tasks = self._load_locked()
 
169
  owner = _owner_id(identity)
170
  requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
171
  with self._lock:
172
+ if self._cleanup_if_due_locked():
173
  self._save_locked()
174
  items = []
175
  missing_ids = []
 
207
  now = _now_iso()
208
  should_start = False
209
  with self._lock:
210
+ cleaned = self._cleanup_if_due_locked()
211
  task = self._tasks.get(key)
212
  if task is not None:
213
  if cleaned:
 
504
  self._tasks.pop(key, None)
505
  return bool(removed_keys)
506
 
507
+ def _cleanup_if_due_locked(self) -> bool:
508
+ now = time.monotonic()
509
+ if now - self._last_cleanup_at < TASK_CLEANUP_INTERVAL_SECONDS:
510
+ return False
511
+ self._last_cleanup_at = now
512
+ return self._cleanup_locked()
513
+
514
 
515
  image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
test/test_account_image_capabilities.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import os
4
  import tempfile
 
5
  import unittest
6
  from pathlib import Path
7
 
@@ -71,6 +72,60 @@ class AccountCapabilityTests(unittest.TestCase):
71
  self.assertTrue(updated["image_quota_unknown"])
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  class TokenLogTests(unittest.TestCase):
75
  def test_anonymize_token_hides_raw_value(self) -> None:
76
  token = "super-secret-token"
 
2
 
3
  import os
4
  import tempfile
5
+ import time
6
  import unittest
7
  from pathlib import Path
8
 
 
72
  self.assertTrue(updated["image_quota_unknown"])
73
 
74
 
75
+ def test_get_available_access_token_uses_fresh_cached_account_info(self) -> None:
76
+ with tempfile.TemporaryDirectory() as tmp_dir:
77
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
78
+ service.add_accounts(["token-1"])
79
+ service.update_account(
80
+ "token-1",
81
+ {
82
+ "status": "姝e父",
83
+ "quota": 5,
84
+ "image_checked_at": time.time(),
85
+ },
86
+ )
87
+
88
+ def fail_fetch_remote_info(_access_token: str, _event: str = "fetch_remote_info") -> dict:
89
+ self.fail("fresh cached account should not be remotely refreshed before image generation")
90
+
91
+ service.fetch_remote_info = fail_fetch_remote_info # type: ignore[method-assign]
92
+
93
+ self.assertEqual(service.get_available_access_token(), "token-1")
94
+ service.release_image_slot("token-1")
95
+
96
+ def test_get_available_access_token_refreshes_stale_account_info(self) -> None:
97
+ with tempfile.TemporaryDirectory() as tmp_dir:
98
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
99
+ service.add_accounts(["token-1"])
100
+ service.update_account(
101
+ "token-1",
102
+ {
103
+ "status": "姝e父",
104
+ "quota": 5,
105
+ "image_checked_at": 1,
106
+ },
107
+ )
108
+ calls = 0
109
+
110
+ def fetch_remote_info(access_token: str, _event: str = "fetch_remote_info") -> dict:
111
+ nonlocal calls
112
+ calls += 1
113
+ return service.update_account(
114
+ access_token,
115
+ {
116
+ "status": "姝e父",
117
+ "quota": 5,
118
+ "image_checked_at": time.time(),
119
+ },
120
+ ) or {}
121
+
122
+ service.fetch_remote_info = fetch_remote_info # type: ignore[method-assign]
123
+
124
+ self.assertEqual(service.get_available_access_token(), "token-1")
125
+ service.release_image_slot("token-1")
126
+ self.assertEqual(calls, 1)
127
+
128
+
129
  class TokenLogTests(unittest.TestCase):
130
  def test_anonymize_token_hides_raw_value(self) -> None:
131
  token = "super-secret-token"
web/src/app/image/components/image-results.tsx CHANGED
@@ -6,7 +6,7 @@ import { Clock3, Download, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "luc
6
  import { Button } from "@/components/ui/button";
7
  import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
8
  import { cn } from "@/lib/utils";
9
- import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
10
 
11
  export type ImageLightboxItem = {
12
  id: string;
@@ -307,6 +307,7 @@ export function ImageResults({
307
  <GeneratingImagePlaceholder
308
  key={image.id}
309
  imageIndex={index}
 
310
  size={turn.size}
311
  isQueued={!image.startedAt}
312
  elapsedSeconds={getElapsedSeconds(image.startedAt || turn.createdAt, now)}
@@ -351,16 +352,18 @@ export function ImageResults({
351
 
352
  function GeneratingImagePlaceholder({
353
  imageIndex,
 
354
  size,
355
  isQueued,
356
  elapsedSeconds,
357
  }: {
358
  imageIndex: number;
 
359
  size: string;
360
  isQueued: boolean;
361
  elapsedSeconds: number;
362
  }) {
363
- const stage = isQueued ? "等待进入生成队列" : getGenerationStage(elapsedSeconds, imageIndex);
364
  const displayStage = stage;
365
  const elapsedLabel = `${isQueued ? "排队中" : "生成中"} ${formatElapsedTime(elapsedSeconds)}`;
366
  return (
@@ -409,8 +412,11 @@ function getElapsedSeconds(value: string, now: number) {
409
  return Math.max(0, Math.floor((now - started) / 1000));
410
  }
411
 
412
- function getGenerationStage(elapsedSeconds: number, imageIndex: number) {
413
- const stageElapsedSeconds = elapsedSeconds + getStageHintOffsetSeconds(imageIndex);
 
 
 
414
  return GENERATION_STAGES.find((stage) => stageElapsedSeconds < stage.until)?.label || GENERATION_STAGES[GENERATION_STAGES.length - 1].label;
415
  }
416
 
 
6
  import { Button } from "@/components/ui/button";
7
  import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
8
  import { cn } from "@/lib/utils";
9
+ import type { ImageConversation, ImageConversationMode, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
10
 
11
  export type ImageLightboxItem = {
12
  id: string;
 
307
  <GeneratingImagePlaceholder
308
  key={image.id}
309
  imageIndex={index}
310
+ mode={turn.mode}
311
  size={turn.size}
312
  isQueued={!image.startedAt}
313
  elapsedSeconds={getElapsedSeconds(image.startedAt || turn.createdAt, now)}
 
352
 
353
  function GeneratingImagePlaceholder({
354
  imageIndex,
355
+ mode,
356
  size,
357
  isQueued,
358
  elapsedSeconds,
359
  }: {
360
  imageIndex: number;
361
+ mode: ImageConversationMode;
362
  size: string;
363
  isQueued: boolean;
364
  elapsedSeconds: number;
365
  }) {
366
+ const stage = isQueued ? "等待进入生成队列" : getGenerationStage(elapsedSeconds, imageIndex, mode === "edit");
367
  const displayStage = stage;
368
  const elapsedLabel = `${isQueued ? "排队中" : "生成中"} ${formatElapsedTime(elapsedSeconds)}`;
369
  return (
 
412
  return Math.max(0, Math.floor((now - started) / 1000));
413
  }
414
 
415
+ function getGenerationStage(elapsedSeconds: number, imageIndex: number, isEditMode = false) {
416
+ if (isEditMode && elapsedSeconds < 5) {
417
+ return "正在分析参考图";
418
+ }
419
+ const stageElapsedSeconds = (isEditMode ? Math.max(0, elapsedSeconds - 5) : elapsedSeconds) + getStageHintOffsetSeconds(imageIndex);
420
  return GENERATION_STAGES.find((stage) => stageElapsedSeconds < stage.until)?.label || GENERATION_STAGES[GENERATION_STAGES.length - 1].label;
421
  }
422
 
web/src/app/image/page.tsx CHANGED
@@ -178,7 +178,7 @@ async function buildReferenceImageFromStoredImage(image: StoredImage, fileName:
178
  }
179
 
180
  function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
181
- const startedAt = task.started_at || (task.status === "running" ? image.startedAt : undefined);
182
  if (task.status === "success") {
183
  const first = task.data?.[0];
184
  if (!first?.b64_json && !first?.url) {
@@ -1040,6 +1040,7 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1040
  };
1041
 
1042
  try {
 
1043
  await updateConversation(conversationId, (current) => {
1044
  const conversation = current ?? snapshot;
1045
  return {
@@ -1049,10 +1050,12 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1049
  turn.id === activeTurn.id
1050
  ? {
1051
  ...turn,
1052
- status: "queued",
1053
  error: undefined,
1054
  images: turn.images.map((image) =>
1055
- image.status === "loading" ? { ...image, taskId: image.taskId || image.id } : image,
 
 
1056
  ),
1057
  }
1058
  : turn,
@@ -1078,6 +1081,7 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1078
  );
1079
  await applyTasks(submitted);
1080
 
 
1081
  while (true) {
1082
  const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
1083
  const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
@@ -1089,7 +1093,8 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1089
  break;
1090
  }
1091
 
1092
- await sleep(2000);
 
1093
  const taskList = await fetchImageTasks(loadingTaskIds);
1094
  if (taskList.items.length > 0) {
1095
  await applyTasks(taskList.items);
@@ -1382,7 +1387,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1382
  name: nextProfile.name || normalizedName,
1383
  wechatOpenid: nextProfile.wechat_openid || session.wechatOpenid,
1384
  });
1385
- window.dispatchEvent(new Event("chatgpt2api:auth-session-updated"));
1386
  setIsProfileOpen(false);
1387
  toast.success("昵称已保存");
1388
  } catch (error) {
 
178
  }
179
 
180
  function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
181
+ const startedAt = task.started_at || image.startedAt;
182
  if (task.status === "success") {
183
  const first = task.data?.[0];
184
  if (!first?.b64_json && !first?.url) {
 
1040
  };
1041
 
1042
  try {
1043
+ const activeStartedAt = new Date().toISOString();
1044
  await updateConversation(conversationId, (current) => {
1045
  const conversation = current ?? snapshot;
1046
  return {
 
1050
  turn.id === activeTurn.id
1051
  ? {
1052
  ...turn,
1053
+ status: "generating",
1054
  error: undefined,
1055
  images: turn.images.map((image) =>
1056
+ image.status === "loading"
1057
+ ? { ...image, taskId: image.taskId || image.id, startedAt: image.startedAt || activeStartedAt }
1058
+ : image,
1059
  ),
1060
  }
1061
  : turn,
 
1081
  );
1082
  await applyTasks(submitted);
1083
 
1084
+ const taskPollStartedAt = Date.now();
1085
  while (true) {
1086
  const latestConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
1087
  const latestTurn = latestConversation?.turns.find((turn) => turn.id === activeTurn.id);
 
1093
  break;
1094
  }
1095
 
1096
+ const taskPollElapsed = Date.now() - taskPollStartedAt;
1097
+ await sleep(taskPollElapsed < 20_000 ? 800 : 2000);
1098
  const taskList = await fetchImageTasks(loadingTaskIds);
1099
  if (taskList.items.length > 0) {
1100
  await applyTasks(taskList.items);
 
1387
  name: nextProfile.name || normalizedName,
1388
  wechatOpenid: nextProfile.wechat_openid || session.wechatOpenid,
1389
  });
 
1390
  setIsProfileOpen(false);
1391
  toast.success("昵称已保存");
1392
  } catch (error) {
web/src/components/top-nav.tsx CHANGED
@@ -68,9 +68,13 @@ export function TopNav() {
68
  };
69
  }, [pathname]);
70
 
71
- const handleLogout = async () => {
72
- await clearStoredAuthSession();
 
73
  router.replace("/login");
 
 
 
74
  };
75
 
76
  if (pathname === "/login" || session === undefined || !session) {
@@ -104,7 +108,7 @@ export function TopNav() {
104
  <button
105
  type="button"
106
  className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
107
- onClick={() => void handleLogout()}
108
  >
109
  退出
110
  </button>
@@ -140,7 +144,7 @@ export function TopNav() {
140
  <button
141
  type="button"
142
  className="py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:text-sm"
143
- onClick={() => void handleLogout()}
144
  >
145
  退出
146
  </button>
 
68
  };
69
  }, [pathname]);
70
 
71
+ const handleLogout = () => {
72
+ const clearPromise = clearStoredAuthSession();
73
+ setSession(null);
74
  router.replace("/login");
75
+ void clearPromise.catch((error) => {
76
+ console.warn("clear auth session failed", error);
77
+ });
78
  };
79
 
80
  if (pathname === "/login" || session === undefined || !session) {
 
108
  <button
109
  type="button"
110
  className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
111
+ onClick={handleLogout}
112
  >
113
  退出
114
  </button>
 
144
  <button
145
  type="button"
146
  className="py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:text-sm"
147
+ onClick={handleLogout}
148
  >
149
  退出
150
  </button>
web/src/store/auth.ts CHANGED
@@ -20,6 +20,15 @@ const authStorage = localforage.createInstance({
20
  storeName: "auth",
21
  });
22
 
 
 
 
 
 
 
 
 
 
23
  function normalizeSession(value: unknown, fallbackKey = ""): StoredAuthSession | null {
24
  if (!value || typeof value !== "object") {
25
  return null;
@@ -49,6 +58,9 @@ export async function getStoredAuthKey() {
49
  if (typeof window === "undefined") {
50
  return "";
51
  }
 
 
 
52
  const value = await authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY);
53
  return String(value || "").trim();
54
  }
@@ -57,6 +69,9 @@ export async function getStoredAuthSession() {
57
  if (typeof window === "undefined") {
58
  return null;
59
  }
 
 
 
60
 
61
  const [storedKey, storedSession] = await Promise.all([
62
  authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY),
@@ -84,10 +99,12 @@ export async function setStoredAuthSession(session: StoredAuthSession) {
84
  return;
85
  }
86
 
 
87
  await Promise.all([
88
  authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key),
89
  authStorage.setItem(AUTH_SESSION_STORAGE_KEY, normalizedSession),
90
  ]);
 
91
  }
92
 
93
  export async function setStoredAuthKey(authKey: string) {
@@ -96,13 +113,17 @@ export async function setStoredAuthKey(authKey: string) {
96
  await clearStoredAuthSession();
97
  return;
98
  }
 
99
  await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedAuthKey);
 
100
  }
101
 
102
  export async function clearStoredAuthSession() {
103
  if (typeof window === "undefined") {
104
  return;
105
  }
 
 
106
  await Promise.all([
107
  authStorage.removeItem(AUTH_KEY_STORAGE_KEY),
108
  authStorage.removeItem(AUTH_SESSION_STORAGE_KEY),
 
20
  storeName: "auth",
21
  });
22
 
23
+ let authSessionClearedInMemory = false;
24
+
25
+ function emitAuthSessionUpdated() {
26
+ if (typeof window === "undefined") {
27
+ return;
28
+ }
29
+ window.dispatchEvent(new Event("chatgpt2api:auth-session-updated"));
30
+ }
31
+
32
  function normalizeSession(value: unknown, fallbackKey = ""): StoredAuthSession | null {
33
  if (!value || typeof value !== "object") {
34
  return null;
 
58
  if (typeof window === "undefined") {
59
  return "";
60
  }
61
+ if (authSessionClearedInMemory) {
62
+ return "";
63
+ }
64
  const value = await authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY);
65
  return String(value || "").trim();
66
  }
 
69
  if (typeof window === "undefined") {
70
  return null;
71
  }
72
+ if (authSessionClearedInMemory) {
73
+ return null;
74
+ }
75
 
76
  const [storedKey, storedSession] = await Promise.all([
77
  authStorage.getItem<string>(AUTH_KEY_STORAGE_KEY),
 
99
  return;
100
  }
101
 
102
+ authSessionClearedInMemory = false;
103
  await Promise.all([
104
  authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedSession.key),
105
  authStorage.setItem(AUTH_SESSION_STORAGE_KEY, normalizedSession),
106
  ]);
107
+ emitAuthSessionUpdated();
108
  }
109
 
110
  export async function setStoredAuthKey(authKey: string) {
 
113
  await clearStoredAuthSession();
114
  return;
115
  }
116
+ authSessionClearedInMemory = false;
117
  await authStorage.setItem(AUTH_KEY_STORAGE_KEY, normalizedAuthKey);
118
+ emitAuthSessionUpdated();
119
  }
120
 
121
  export async function clearStoredAuthSession() {
122
  if (typeof window === "undefined") {
123
  return;
124
  }
125
+ authSessionClearedInMemory = true;
126
+ emitAuthSessionUpdated();
127
  await Promise.all([
128
  authStorage.removeItem(AUTH_KEY_STORAGE_KEY),
129
  authStorage.removeItem(AUTH_SESSION_STORAGE_KEY),