xiaolongmr commited on
Commit ·
bbc8fdb
1
Parent(s): dbfb96e
Improve image generation and auth responsiveness
Browse files- services/account_service.py +20 -1
- services/image_service.py +14 -1
- services/image_storage_service.py +15 -1
- services/image_task_service.py +11 -2
- test/test_account_image_capabilities.py +55 -0
- web/src/app/image/components/image-results.tsx +10 -4
- web/src/app/image/page.tsx +9 -5
- web/src/components/top-nav.tsx +8 -4
- web/src/store/auth.ts +21 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 ||
|
| 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: "
|
| 1053 |
error: undefined,
|
| 1054 |
images: turn.images.map((image) =>
|
| 1055 |
-
image.status === "loading"
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 =
|
| 72 |
-
|
|
|
|
| 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={
|
| 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={
|
| 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),
|