diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py index ab12b46..9ab790f 100644 --- a/app/api/v1/admin.py +++ b/app/api/v1/admin.py @@ -1,5 +1,14 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, Query +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + Query, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import HTMLResponse, StreamingResponse +from typing import Optional from pydantic import BaseModel from app.core.auth import verify_api_key, verify_app_key, get_admin_api_key from app.core.config import config, get_config @@ -9,12 +18,19 @@ from app.core.exceptions import AppException from app.services.token.manager import get_token_manager from app.services.grok.utils.batch import run_in_batches import os +import time +import uuid from pathlib import Path import aiofiles import asyncio import orjson from app.core.logger import logger +from app.api.v1.image import resolve_aspect_ratio from app.services.grok.services.voice import VoiceService +from app.services.grok.services.image import image_service +from app.services.grok.models.model import ModelService +from app.services.grok.processors.image_ws_processors import ImageWSCollectProcessor +from app.services.token import EffortType TEMPLATE_DIR = Path(__file__).parent.parent.parent / "static" @@ -151,6 +167,12 @@ async def admin_voice_page(): return await render_template("voice/voice.html") +@router.get("/admin/imagine", response_class=HTMLResponse, include_in_schema=False) +async def admin_imagine_page(): + """Imagine 图片瀑布流""" + return await render_template("imagine/imagine.html") + + class VoiceTokenResponse(BaseModel): token: str url: str @@ -216,6 +238,228 @@ async def admin_voice_token( ) +def _verify_ws_api_key(websocket: WebSocket) -> bool: + api_key = get_admin_api_key() + if not api_key: + return True + key = websocket.query_params.get("api_key") + return key == api_key + + +@router.websocket("/api/v1/admin/imagine/ws") +async def admin_imagine_ws(websocket: WebSocket): + if not _verify_ws_api_key(websocket): + await websocket.close(code=1008) + return + + await websocket.accept() + stop_event = asyncio.Event() + run_task: Optional[asyncio.Task] = None + + async def _send(payload: dict) -> bool: + try: + await websocket.send_text(orjson.dumps(payload).decode()) + return True + except Exception: + return False + + async def _stop_run(): + nonlocal run_task + stop_event.set() + if run_task and not run_task.done(): + run_task.cancel() + try: + await run_task + except Exception: + pass + run_task = None + stop_event.clear() + + async def _run(prompt: str, aspect_ratio: str): + model_id = "grok-imagine-1.0" + model_info = ModelService.get(model_id) + if not model_info or not model_info.is_image: + await _send( + { + "type": "error", + "message": "Image model is not available.", + "code": "model_not_supported", + } + ) + return + + token_mgr = await get_token_manager() + enable_nsfw = bool(get_config("image.image_ws_nsfw", True)) + sequence = 0 + run_id = uuid.uuid4().hex + + await _send( + { + "type": "status", + "status": "running", + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "run_id": run_id, + } + ) + + while not stop_event.is_set(): + try: + await token_mgr.reload_if_stale() + token = None + for pool_name in ModelService.pool_candidates_for_model( + model_info.model_id + ): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + await _send( + { + "type": "error", + "message": "No available tokens. Please try again later.", + "code": "rate_limit_exceeded", + } + ) + await asyncio.sleep(2) + continue + + upstream = image_service.stream( + token=token, + prompt=prompt, + aspect_ratio=aspect_ratio, + n=6, + enable_nsfw=enable_nsfw, + ) + + processor = ImageWSCollectProcessor( + model_info.model_id, + token, + n=6, + response_format="b64_json", + ) + + start_at = time.time() + images = await processor.process(upstream) + elapsed_ms = int((time.time() - start_at) * 1000) + + if images and all(img and img != "error" for img in images): + # 一次发送所有 6 张图片 + for img_b64 in images: + sequence += 1 + await _send( + { + "type": "image", + "b64_json": img_b64, + "sequence": sequence, + "created_at": int(time.time() * 1000), + "elapsed_ms": elapsed_ms, + "aspect_ratio": aspect_ratio, + "run_id": run_id, + } + ) + + # 消耗 token(6 张图片按高成本计算) + try: + effort = ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + await token_mgr.consume(token, effort) + except Exception as e: + logger.warning(f"Failed to consume token: {e}") + else: + await _send( + { + "type": "error", + "message": "Image generation returned empty data.", + "code": "empty_image", + } + ) + + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Imagine stream error: {e}") + await _send( + { + "type": "error", + "message": str(e), + "code": "internal_error", + } + ) + await asyncio.sleep(1.5) + + await _send({"type": "status", "status": "stopped", "run_id": run_id}) + + try: + while True: + try: + raw = await websocket.receive_text() + except (RuntimeError, WebSocketDisconnect): + # WebSocket already closed or disconnected + break + + try: + payload = orjson.loads(raw) + except Exception: + await _send( + { + "type": "error", + "message": "Invalid message format.", + "code": "invalid_payload", + } + ) + continue + + msg_type = payload.get("type") + if msg_type == "start": + prompt = str(payload.get("prompt") or "").strip() + if not prompt: + await _send( + { + "type": "error", + "message": "Prompt cannot be empty.", + "code": "empty_prompt", + } + ) + continue + ratio = str(payload.get("aspect_ratio") or "2:3").strip() + if not ratio: + ratio = "2:3" + ratio = resolve_aspect_ratio(ratio) + await _stop_run() + stop_event.clear() + run_task = asyncio.create_task(_run(prompt, ratio)) + elif msg_type == "stop": + await _stop_run() + elif msg_type == "ping": + await _send({"type": "pong"}) + else: + await _send( + { + "type": "error", + "message": "Unknown command.", + "code": "unknown_command", + } + ) + except WebSocketDisconnect: + logger.debug("WebSocket disconnected by client") + except Exception as e: + logger.warning(f"WebSocket error: {e}") + finally: + await _stop_run() + + try: + from starlette.websockets import WebSocketState + if websocket.client_state == WebSocketState.CONNECTED: + await websocket.close(code=1000, reason="Server closing connection") + except Exception as e: + logger.debug(f"WebSocket close ignored: {e}") + + @router.post("/api/v1/admin/login", dependencies=[Depends(verify_app_key)]) async def admin_login_api(): """管理后台登录验证(使用 app_key)""" diff --git a/app/static/cache/cache.html b/app/static/cache/cache.html index de3141b..128ab97 100644 --- a/app/static/cache/cache.html +++ b/app/static/cache/cache.html @@ -198,7 +198,7 @@ - + diff --git a/app/static/common/header.html b/app/static/common/header.html index 5283718..7303639 100644 --- a/app/static/common/header.html +++ b/app/static/common/header.html @@ -25,9 +25,10 @@ diff --git a/app/static/common/header.js b/app/static/common/header.js index ab5f541..73136fb 100644 --- a/app/static/common/header.js +++ b/app/static/common/header.js @@ -2,7 +2,7 @@ async function loadAdminHeader() { const container = document.getElementById('app-header'); if (!container) return; try { - const res = await fetch('/static/common/header.html?v=3'); + const res = await fetch('/static/common/header.html?v=4'); if (!res.ok) return; container.innerHTML = await res.text(); const path = window.location.pathname; diff --git a/app/static/config/config.html b/app/static/config/config.html index 6fdff9e..b19411c 100644 --- a/app/static/config/config.html +++ b/app/static/config/config.html @@ -47,7 +47,7 @@ - + diff --git a/app/static/imagine/imagine.css b/app/static/imagine/imagine.css new file mode 100644 index 0000000..2e18b36 --- /dev/null +++ b/app/static/imagine/imagine.css @@ -0,0 +1,679 @@ +.imagine-top-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + margin-bottom: 24px; + align-items: stretch; +} + +@media (max-width: 1024px) { + .imagine-top-grid { + grid-template-columns: 1fr; + } +} + +.imagine-card { + background: #fff; + border: none !important; + border-radius: 14px; + padding: 20px; + box-shadow: none !important; + display: flex; + flex-direction: column; + overflow: visible; +} + +.imagine-card-collapsible { + transition: all 0.3s ease; +} + +.card-title-row { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + margin-bottom: 12px; +} + +.status-header-row { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; + margin-bottom: 12px; +} + +.status-header-row .card-title { + margin-bottom: 0; +} + +.status-header-row .status-text { + flex: 1; + text-align: right; +} + +.status-header-row .collapse-icon { + flex-shrink: 0; +} + +.collapse-icon { + transition: transform 0.3s ease; + flex-shrink: 0; + color: var(--accents-4); +} + +.imagine-card-collapsible.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.card-title-row:hover .collapse-icon { + color: var(--accents-7); +} + +.card-content { + overflow: hidden; + transition: max-height 0.3s ease, opacity 0.2s ease; + max-height: 2000px; + opacity: 1; +} + +.imagine-card-collapsible.collapsed .card-content { + max-height: 0; + opacity: 0; + margin: 0; + padding: 0; +} + +.imagine-card-collapsible.collapsed .card-title-row { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed .card-title { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed .status-header-row { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed { + padding: 12px 20px; +} + +.floating-actions { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + background: #fff; + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + cursor: move; + user-select: none; + white-space: nowrap; +} + +.floating-actions:active { + cursor: grabbing; +} + +.shadow-xl { + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2) !important; +} + +.toolbar-sep { + display: inline-block; + width: 1px; + height: 14px; + background: var(--border); + margin: 0 6px; +} + +.imagine-textarea { + min-height: 96px; + resize: vertical; + line-height: 1.5; +} + +.settings-layout { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-top { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + align-items: start; +} + +.settings-prompt { + display: flex; + flex-direction: column; +} + +.settings-options { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-bottom-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + align-items: start; +} + +.settings-toggles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.settings-toggle { + display: flex; + flex-direction: column; +} + +.settings-toggle .field-label { + margin-bottom: 6px; +} + +.settings-folder { + display: flex; + flex-direction: column; +} + +.settings-folder .field-label { + margin-bottom: 6px; +} + +.folder-select-btn { + width: 100%; + justify-content: flex-start; + font-size: 12px; + height: 30px; + padding: 0 12px; + gap: 8px; + align-items: center; + white-space: normal; +} + +.folder-select-btn #folderPath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 768px) { + .settings-top { + grid-template-columns: 1fr; + } + + .settings-bottom-row { + grid-template-columns: 1fr; + } + + .settings-toggles { + grid-template-columns: 1fr; + } +} + + +.waterfall-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + min-height: 28px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--accents-7); + margin: 0; +} + +.waterfall-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.batch-download-btn { + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + color: var(--fg); + flex-shrink: 0; +} + +.batch-download-btn svg { + width: 14px; + height: 14px; +} + +.batch-download-btn:hover { + background: var(--hover-bg); + border-color: #000; +} + +.selection-toolbar { + display: none; + align-items: center; + gap: 8px; +} + +.selection-toolbar.hidden { + display: none; +} + +.selection-toolbar:not(.hidden) { + display: flex; +} + +.selection-btn { + height: 28px; + padding: 0 10px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + border: 1px solid var(--border); + background: #fff; + color: var(--fg); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.selection-btn:hover { + background: var(--hover-bg); + border-color: #000; +} + +.selection-btn-primary { + background: #000; + color: #fff; + border-color: #000; +} + +.selection-btn-primary:hover { + background: #000; + opacity: 0.85; +} + +.selection-btn-primary:disabled { + background: #000; + opacity: 0.5; + cursor: not-allowed; +} + +.field-label { + display: block; + font-size: 11px; + color: var(--accents-4); + margin-bottom: 6px; +} + +.status-text { + font-size: 11px; + color: var(--accents-4); +} + +.status-text.connected { + color: #059669; +} + +.status-text.connecting { + color: #d97706; +} + +.status-text.error { + color: #dc2626; +} + + +.meta-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.meta-item { + padding: 10px 12px; + border-radius: 10px; + background: #f7f7f8; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 40px; +} + +.meta-label { + font-size: 10px; + color: var(--accents-4); +} + +.meta-value { + font-size: 12px; + font-weight: 600; + color: var(--accents-7); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 150px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.toggle input { + display: none; +} + +.toggle-track { + width: 38px; + height: 20px; + background: #e5e7eb; + border-radius: 999px; + position: relative; + transition: background 0.2s ease; +} + +.toggle-track::after { + content: ""; + width: 14px; + height: 14px; + background: #fff; + border-radius: 999px; + position: absolute; + top: 3px; + left: 3px; + box-shadow: none; + transition: transform 0.2s ease; +} + +.toggle input:checked + .toggle-track { + background: #111; +} + +.toggle input:checked + .toggle-track::after { + transform: translateX(18px); +} + +.toggle-label { + font-size: 12px; + color: var(--accents-4); +} + +.imagine-empty { + text-align: center; + padding: 28px 0 8px; + font-size: 12px; + color: var(--accents-4); +} + +.waterfall { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + width: 100%; +} + +@media (max-width: 1200px) { + .waterfall { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 768px) { + .waterfall { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .waterfall { + grid-template-columns: 1fr; + } +} + +.waterfall-item { + border-radius: 14px; + overflow: hidden; + border: none !important; + background: #fff; + box-shadow: none !important; + animation: riseIn 0.4s ease; + cursor: pointer; + transition: transform 0.2s ease; + display: flex; + flex-direction: column; + position: relative; +} + +.waterfall-item:hover { + transform: scale(1.02); +} + +.waterfall-item.selection-mode { + cursor: default; +} + +.waterfall-item.selection-mode:hover { + transform: none; +} + +.waterfall-item img { + width: 100%; + height: auto; + display: block; + border-radius: 14px 14px 0 0; +} + +.waterfall-item .image-checkbox { + position: absolute; + top: 8px; + left: 8px; + width: 24px; + height: 24px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #ddd; + border-radius: 4px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: all 0.2s; + backdrop-filter: blur(4px); + z-index: 2; +} + +.waterfall-item.selection-mode .image-checkbox { + display: flex; +} + +.waterfall-item .image-checkbox:hover { + border-color: #000; + background: rgba(255, 255, 255, 1); +} + +.waterfall-item.selected .image-checkbox { + background: #000; + border-color: #000; +} + +.waterfall-item.selected .image-checkbox::after { + content: ''; + width: 8px; + height: 12px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-top: -2px; +} + +.waterfall-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 10px 12px; + font-size: 11px; + color: var(--accents-4); + background: #fff; + border-radius: 0 0 14px 14px; +} + +.waterfall-meta span { + font-family: 'Geist Mono', ui-monospace, monospace; + color: var(--accents-5); +} + + +@keyframes riseIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 图片放大预览 */ +.image-lightbox { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 9999; + justify-content: center; + align-items: center; + cursor: zoom-out; + animation: fadeIn 0.2s ease; +} + +.image-lightbox.active { + display: flex; +} + +.image-lightbox img { + max-width: 90%; + max-height: 90%; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 20px; + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10000; + transition: all 0.2s; + backdrop-filter: blur(10px); +} + +.lightbox-nav:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); +} + +.lightbox-nav:active { + transform: translateY(-50%) scale(0.95); +} + +.lightbox-prev { + left: 30px; +} + +.lightbox-next { + right: 30px; +} + +.lightbox-nav:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.lightbox-nav:disabled:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-50%); +} + +@media (max-width: 768px) { + .lightbox-nav { + width: 40px; + height: 40px; + } + + .lightbox-prev { + left: 15px; + } + + .lightbox-next { + right: 15px; + } +} diff --git a/app/static/imagine/imagine.html b/app/static/imagine/imagine.html new file mode 100644 index 0000000..c95688e --- /dev/null +++ b/app/static/imagine/imagine.html @@ -0,0 +1,201 @@ + + + + + + + Grok2API - Imagine 瀑布流 + + + + + + + + + + +
+
+ +
+
+
+

Imagine 瀑布流

+

通过 WebSocket 持续生成图片,实时展示 base64 瀑布流。

+
+ +
+ +
+
+
生成设置
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ +
+
+
连接状态
+ 未连接 + + + +
+
+
+
+
图片数量
+
0
+
+
+
活跃任务
+
0
+
+
+
平均耗时
+
-
+
+
+
最后错误
+
-
+
+
+
+
+
+ +
+
+
瀑布流
+
+ + +
+
+
等待连接中...
+
+
+
+ + + + +
+ + + + +
+ + + + + + + + + + + + diff --git a/app/static/imagine/imagine.js b/app/static/imagine/imagine.js new file mode 100644 index 0000000..9a92966 --- /dev/null +++ b/app/static/imagine/imagine.js @@ -0,0 +1,768 @@ +(() => { + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const clearBtn = document.getElementById('clearBtn'); + const promptInput = document.getElementById('promptInput'); + const ratioSelect = document.getElementById('ratioSelect'); + const concurrentSelect = document.getElementById('concurrentSelect'); + const autoScrollToggle = document.getElementById('autoScrollToggle'); + const autoDownloadToggle = document.getElementById('autoDownloadToggle'); + const selectFolderBtn = document.getElementById('selectFolderBtn'); + const folderPath = document.getElementById('folderPath'); + const statusText = document.getElementById('statusText'); + const countValue = document.getElementById('countValue'); + const activeValue = document.getElementById('activeValue'); + const latencyValue = document.getElementById('latencyValue'); + const errorValue = document.getElementById('errorValue'); + const waterfall = document.getElementById('waterfall'); + const emptyState = document.getElementById('emptyState'); + const lightbox = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightboxImg'); + const closeLightbox = document.getElementById('closeLightbox'); + + let wsConnections = []; + let imageCount = 0; + let totalLatency = 0; + let latencyCount = 0; + let lastRunId = ''; + let isRunning = false; + let directoryHandle = null; + let useFileSystemAPI = false; + let isSelectionMode = false; + let selectedImages = new Set(); + + function toast(message, type) { + if (typeof showToast === 'function') { + showToast(message, type); + } + } + + function setStatus(state, text) { + if (!statusText) return; + statusText.textContent = text; + statusText.classList.remove('connected', 'connecting', 'error'); + if (state) { + statusText.classList.add(state); + } + } + + function setButtons(connected) { + if (!startBtn || !stopBtn) return; + if (connected) { + startBtn.classList.add('hidden'); + stopBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + stopBtn.classList.add('hidden'); + startBtn.disabled = false; + } + } + + function updateCount(value) { + if (countValue) { + countValue.textContent = String(value); + } + } + + function updateActive() { + if (activeValue) { + const active = wsConnections.filter(ws => ws && ws.readyState === WebSocket.OPEN).length; + activeValue.textContent = String(active); + } + } + + + function updateLatency(value) { + if (value) { + totalLatency += value; + latencyCount += 1; + const avg = Math.round(totalLatency / latencyCount); + if (latencyValue) { + latencyValue.textContent = `${avg} ms`; + } + } else { + if (latencyValue) { + latencyValue.textContent = '-'; + } + } + } + + function updateError(value) { + if (errorValue) { + errorValue.textContent = value || '-'; + } + } + + function inferMime(base64) { + if (!base64) return 'image/jpeg'; + if (base64.startsWith('iVBOR')) return 'image/png'; + if (base64.startsWith('/9j/')) return 'image/jpeg'; + if (base64.startsWith('R0lGOD')) return 'image/gif'; + return 'image/jpeg'; + } + + async function saveToFileSystem(base64, filename) { + try { + if (!directoryHandle) { + return false; + } + + const mime = inferMime(base64); + const ext = mime === 'image/png' ? 'png' : 'jpg'; + const finalFilename = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`; + + const fileHandle = await directoryHandle.getFileHandle(finalFilename, { create: true }); + const writable = await fileHandle.createWritable(); + + // Convert base64 to blob + const byteString = atob(base64); + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + const blob = new Blob([ab], { type: mime }); + + await writable.write(blob); + await writable.close(); + return true; + } catch (e) { + console.error('File System API save failed:', e); + return false; + } + } + + function downloadImage(base64, filename) { + const mime = inferMime(base64); + const dataUrl = `data:${mime};base64,${base64}`; + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + function appendImage(base64, meta) { + if (!waterfall) return; + if (emptyState) { + emptyState.style.display = 'none'; + } + + const item = document.createElement('div'); + item.className = 'waterfall-item'; + + const img = document.createElement('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = meta && meta.sequence ? `image-${meta.sequence}` : 'image'; + img.src = `data:${inferMime(base64)};base64,${base64}`; + + const metaBar = document.createElement('div'); + metaBar.className = 'waterfall-meta'; + const left = document.createElement('div'); + left.textContent = meta && meta.sequence ? `#${meta.sequence}` : '#'; + const right = document.createElement('span'); + if (meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } else { + right.textContent = ''; + } + + metaBar.appendChild(left); + metaBar.appendChild(right); + + item.appendChild(img); + item.appendChild(metaBar); + + waterfall.appendChild(item); + + if (autoScrollToggle && autoScrollToggle.checked) { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + } + + if (autoDownloadToggle && autoDownloadToggle.checked) { + const timestamp = Date.now(); + const seq = meta && meta.sequence ? meta.sequence : 'unknown'; + const ext = inferMime(base64) === 'image/png' ? 'png' : 'jpg'; + const filename = `imagine_${timestamp}_${seq}.${ext}`; + + if (useFileSystemAPI && directoryHandle) { + saveToFileSystem(base64, filename).catch(() => { + downloadImage(base64, filename); + }); + } else { + downloadImage(base64, filename); + } + } + } + + function handleMessage(raw) { + let data = null; + try { + data = JSON.parse(raw); + } catch (e) { + return; + } + if (!data || typeof data !== 'object') return; + + if (data.type === 'image') { + imageCount += 1; + updateCount(imageCount); + updateLatency(data.elapsed_ms); + updateError(''); + appendImage(data.b64_json, data); + } else if (data.type === 'status') { + if (data.status === 'running') { + setStatus('connected', '生成中'); + lastRunId = data.run_id || ''; + } else if (data.status === 'stopped') { + if (data.run_id && lastRunId && data.run_id !== lastRunId) { + return; + } + setStatus('', '已停止'); + } + } else if (data.type === 'error') { + const message = data.message || '生成失败'; + updateError(message); + toast(message, 'error'); + } + } + + async function startConnection() { + const prompt = promptInput ? promptInput.value.trim() : ''; + if (!prompt) { + toast('请输入提示词', 'error'); + return; + } + + const apiKey = await ensureApiKey(); + if (apiKey === null) { + toast('请先登录后台', 'error'); + return; + } + + const concurrent = concurrentSelect ? parseInt(concurrentSelect.value, 10) : 1; + + if (isRunning) { + toast('已在运行中', 'warning'); + return; + } + + isRunning = true; + setStatus('connecting', '连接中'); + startBtn.disabled = true; + + const rawKey = apiKey.startsWith('Bearer ') ? apiKey.slice(7) : apiKey; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${protocol}://${window.location.host}/api/v1/admin/imagine/ws?api_key=${encodeURIComponent(rawKey)}`; + + wsConnections = []; + + for (let i = 0; i < concurrent; i++) { + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + updateActive(); + if (i === 0) { + setStatus('connected', '生成中'); + setButtons(true); + toast(`已启动 ${concurrent} 个并发任务`, 'success'); + } + sendStart(prompt, ws); + }; + + ws.onmessage = (event) => { + handleMessage(event.data); + }; + + ws.onclose = () => { + updateActive(); + const remaining = wsConnections.filter(w => w && w.readyState === WebSocket.OPEN).length; + if (remaining === 0) { + setStatus('', '未连接'); + setButtons(false); + isRunning = false; + } + }; + + ws.onerror = () => { + updateActive(); + if (i === 0 && wsConnections.filter(w => w && w.readyState === WebSocket.OPEN).length === 0) { + setStatus('error', '连接错误'); + startBtn.disabled = false; + isRunning = false; + } + }; + + wsConnections.push(ws); + } + } + + function sendStart(promptOverride, targetWs) { + const ws = targetWs || wsConnections[0]; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const prompt = promptOverride || (promptInput ? promptInput.value.trim() : ''); + const ratio = ratioSelect ? ratioSelect.value : '2:3'; + const payload = { + type: 'start', + prompt, + aspect_ratio: ratio + }; + ws.send(JSON.stringify(payload)); + updateError(''); + } + + function stopConnection() { + if (wsConnections.length === 0) { + setStatus('', '未连接'); + setButtons(false); + return; + } + + wsConnections.forEach(ws => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'stop' })); + } catch (e) { + // ignore + } + ws.close(1000, 'client stop'); + } + }); + + wsConnections = []; + isRunning = false; + updateActive(); + } + + function clearImages() { + if (waterfall) { + waterfall.innerHTML = ''; + } + imageCount = 0; + totalLatency = 0; + latencyCount = 0; + updateCount(imageCount); + updateLatency(''); + updateError(''); + if (emptyState) { + emptyState.style.display = 'block'; + } + } + + if (startBtn) { + startBtn.addEventListener('click', () => startConnection()); + } + + if (stopBtn) { + stopBtn.addEventListener('click', () => stopConnection()); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => clearImages()); + } + + if (promptInput) { + promptInput.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + startConnection(); + } + }); + } + + if (ratioSelect) { + ratioSelect.addEventListener('change', () => { + if (isRunning) { + wsConnections.forEach(ws => { + if (ws && ws.readyState === WebSocket.OPEN) { + sendStart(null, ws); + } + }); + } + }); + } + + // File System API support check + if ('showDirectoryPicker' in window) { + if (selectFolderBtn) { + selectFolderBtn.disabled = false; + selectFolderBtn.addEventListener('click', async () => { + try { + directoryHandle = await window.showDirectoryPicker({ + mode: 'readwrite' + }); + useFileSystemAPI = true; + if (folderPath) { + folderPath.textContent = directoryHandle.name; + selectFolderBtn.style.color = '#059669'; + } + toast('已选择文件夹: ' + directoryHandle.name, 'success'); + } catch (e) { + if (e.name !== 'AbortError') { + toast('选择文件夹失败', 'error'); + } + } + }); + } + } + + // Enable/disable folder selection based on auto-download + if (autoDownloadToggle && selectFolderBtn) { + autoDownloadToggle.addEventListener('change', () => { + if (autoDownloadToggle.checked && 'showDirectoryPicker' in window) { + selectFolderBtn.disabled = false; + } else { + selectFolderBtn.disabled = true; + } + }); + } + + // Collapsible cards - 点击"连接状态"标题控制所有卡片 + const statusToggle = document.getElementById('statusToggle'); + + if (statusToggle) { + statusToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const cards = document.querySelectorAll('.imagine-card-collapsible'); + const allCollapsed = Array.from(cards).every(card => card.classList.contains('collapsed')); + + cards.forEach(card => { + if (allCollapsed) { + card.classList.remove('collapsed'); + } else { + card.classList.add('collapsed'); + } + }); + }); + } + + // Batch download functionality + const batchDownloadBtn = document.getElementById('batchDownloadBtn'); + const selectionToolbar = document.getElementById('selectionToolbar'); + const toggleSelectAllBtn = document.getElementById('toggleSelectAllBtn'); + const downloadSelectedBtn = document.getElementById('downloadSelectedBtn'); + + function enterSelectionMode() { + isSelectionMode = true; + selectedImages.clear(); + selectionToolbar.classList.remove('hidden'); + + const items = document.querySelectorAll('.waterfall-item'); + items.forEach(item => { + item.classList.add('selection-mode'); + }); + + updateSelectedCount(); + } + + function exitSelectionMode() { + isSelectionMode = false; + selectedImages.clear(); + selectionToolbar.classList.add('hidden'); + + const items = document.querySelectorAll('.waterfall-item'); + items.forEach(item => { + item.classList.remove('selection-mode', 'selected'); + }); + } + + function toggleSelectionMode() { + if (isSelectionMode) { + exitSelectionMode(); + } else { + enterSelectionMode(); + } + } + + function toggleImageSelection(item) { + if (!isSelectionMode) return; + + if (item.classList.contains('selected')) { + item.classList.remove('selected'); + selectedImages.delete(item); + } else { + item.classList.add('selected'); + selectedImages.add(item); + } + + updateSelectedCount(); + } + + function updateSelectedCount() { + const countSpan = document.getElementById('selectedCount'); + if (countSpan) { + countSpan.textContent = selectedImages.size; + } + if (downloadSelectedBtn) { + downloadSelectedBtn.disabled = selectedImages.size === 0; + } + + // Update toggle select all button text + if (toggleSelectAllBtn) { + const items = document.querySelectorAll('.waterfall-item'); + const allSelected = items.length > 0 && selectedImages.size === items.length; + toggleSelectAllBtn.textContent = allSelected ? '取消全选' : '全选'; + } + } + + function toggleSelectAll() { + const items = document.querySelectorAll('.waterfall-item'); + const allSelected = items.length > 0 && selectedImages.size === items.length; + + if (allSelected) { + // Deselect all + items.forEach(item => { + item.classList.remove('selected'); + }); + selectedImages.clear(); + } else { + // Select all + items.forEach(item => { + item.classList.add('selected'); + selectedImages.add(item); + }); + } + + updateSelectedCount(); + } + + async function downloadSelectedImages() { + if (selectedImages.size === 0) { + toast('请先选择要下载的图片', 'warning'); + return; + } + + if (typeof JSZip === 'undefined') { + toast('JSZip 库加载失败,请刷新页面重试', 'error'); + return; + } + + toast(`正在打包 ${selectedImages.size} 张图片...`, 'info'); + downloadSelectedBtn.disabled = true; + downloadSelectedBtn.textContent = '打包中...'; + + const zip = new JSZip(); + const imgFolder = zip.folder('images'); + let processed = 0; + + try { + for (const item of selectedImages) { + const url = item.dataset.imageUrl; + const prompt = item.dataset.prompt || 'image'; + + try { + const response = await fetch(url); + const blob = await response.blob(); + const filename = `${prompt.substring(0, 30).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')}_${processed + 1}.png`; + imgFolder.file(filename, blob); + processed++; + + // Update progress + downloadSelectedBtn.innerHTML = `打包中... (${processed}/${selectedImages.size})`; + } catch (error) { + console.error('Failed to fetch image:', error); + } + } + + if (processed === 0) { + toast('没有成功获取任何图片', 'error'); + return; + } + + // Generate zip file + downloadSelectedBtn.textContent = '生成压缩包...'; + const content = await zip.generateAsync({ type: 'blob' }); + + // Download zip + const link = document.createElement('a'); + link.href = URL.createObjectURL(content); + link.download = `imagine_${new Date().toISOString().slice(0, 10)}_${Date.now()}.zip`; + link.click(); + URL.revokeObjectURL(link.href); + + toast(`成功打包 ${processed} 张图片`, 'success'); + exitSelectionMode(); + } catch (error) { + console.error('Download failed:', error); + toast('打包失败,请重试', 'error'); + } finally { + downloadSelectedBtn.disabled = false; + downloadSelectedBtn.innerHTML = `下载选中 (${selectedImages.size})`; + } + } + + if (batchDownloadBtn) { + batchDownloadBtn.addEventListener('click', toggleSelectionMode); + } + + if (toggleSelectAllBtn) { + toggleSelectAllBtn.addEventListener('click', toggleSelectAll); + } + + if (downloadSelectedBtn) { + downloadSelectedBtn.addEventListener('click', downloadSelectedImages); + } + + + // Handle image/checkbox clicks in waterfall + if (waterfall) { + waterfall.addEventListener('click', (e) => { + const item = e.target.closest('.waterfall-item'); + if (!item) return; + + if (isSelectionMode) { + // In selection mode, clicking anywhere on the item toggles selection + toggleImageSelection(item); + } else { + // In normal mode, only clicking the image opens lightbox + if (e.target.closest('.waterfall-item img')) { + const img = e.target.closest('.waterfall-item img'); + const images = getAllImages(); + const index = images.indexOf(img); + + if (index !== -1) { + updateLightbox(index); + lightbox.classList.add('active'); + } + } + } + }); + } + + // Lightbox for image preview with navigation + const lightboxPrev = document.getElementById('lightboxPrev'); + const lightboxNext = document.getElementById('lightboxNext'); + let currentImageIndex = -1; + + function getAllImages() { + return Array.from(document.querySelectorAll('.waterfall-item img')); + } + + function updateLightbox(index) { + const images = getAllImages(); + if (index < 0 || index >= images.length) return; + + currentImageIndex = index; + lightboxImg.src = images[index].src; + + // Update navigation buttons state + if (lightboxPrev) lightboxPrev.disabled = (index === 0); + if (lightboxNext) lightboxNext.disabled = (index === images.length - 1); + } + + function showPrevImage() { + if (currentImageIndex > 0) { + updateLightbox(currentImageIndex - 1); + } + } + + function showNextImage() { + const images = getAllImages(); + if (currentImageIndex < images.length - 1) { + updateLightbox(currentImageIndex + 1); + } + } + + if (lightbox && closeLightbox) { + closeLightbox.addEventListener('click', (e) => { + e.stopPropagation(); + lightbox.classList.remove('active'); + currentImageIndex = -1; + }); + + lightbox.addEventListener('click', () => { + lightbox.classList.remove('active'); + currentImageIndex = -1; + }); + + // Prevent closing when clicking on the image + if (lightboxImg) { + lightboxImg.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + // Navigation buttons + if (lightboxPrev) { + lightboxPrev.addEventListener('click', (e) => { + e.stopPropagation(); + showPrevImage(); + }); + } + + if (lightboxNext) { + lightboxNext.addEventListener('click', (e) => { + e.stopPropagation(); + showNextImage(); + }); + } + + // Keyboard navigation + document.addEventListener('keydown', (e) => { + if (!lightbox.classList.contains('active')) return; + + if (e.key === 'Escape') { + lightbox.classList.remove('active'); + currentImageIndex = -1; + } else if (e.key === 'ArrowLeft') { + showPrevImage(); + } else if (e.key === 'ArrowRight') { + showNextImage(); + } + }); + } + + // Make floating actions draggable + const floatingActions = document.getElementById('floatingActions'); + if (floatingActions) { + let isDragging = false; + let startX, startY, initialLeft, initialTop; + + floatingActions.style.touchAction = 'none'; + + floatingActions.addEventListener('pointerdown', (e) => { + if (e.target.tagName.toLowerCase() === 'button' || e.target.closest('button')) return; + + e.preventDefault(); + isDragging = true; + floatingActions.setPointerCapture(e.pointerId); + startX = e.clientX; + startY = e.clientY; + + const rect = floatingActions.getBoundingClientRect(); + + if (!floatingActions.style.left || floatingActions.style.left === '') { + floatingActions.style.left = rect.left + 'px'; + floatingActions.style.top = rect.top + 'px'; + floatingActions.style.transform = 'none'; + floatingActions.style.bottom = 'auto'; + } + + initialLeft = parseFloat(floatingActions.style.left); + initialTop = parseFloat(floatingActions.style.top); + + floatingActions.classList.add('shadow-xl'); + }); + + document.addEventListener('pointermove', (e) => { + if (!isDragging) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + floatingActions.style.left = `${initialLeft + dx}px`; + floatingActions.style.top = `${initialTop + dy}px`; + }); + + document.addEventListener('pointerup', (e) => { + if (isDragging) { + isDragging = false; + floatingActions.releasePointerCapture(e.pointerId); + floatingActions.classList.remove('shadow-xl'); + } + }); + } +})(); diff --git a/app/static/token/token.html b/app/static/token/token.html index c16113a..f43aec9 100644 --- a/app/static/token/token.html +++ b/app/static/token/token.html @@ -294,7 +294,7 @@ - + diff --git a/app/static/voice/voice.html b/app/static/voice/voice.html index f90d4e1..e9b0e92 100644 --- a/app/static/voice/voice.html +++ b/app/static/voice/voice.html @@ -148,7 +148,7 @@ - + diff --git a/pyproject.toml b/pyproject.toml index 64171bf..e2fc17b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastapi>=0.119.0", "greenlet>=3.3.1", "httpx>=0.28.1", + "itsdangerous>=2.2.0", "livekit>=1.0.25", "loguru>=0.7.3", "orjson>=3.11.4", @@ -25,6 +26,7 @@ dependencies = [ "sqlalchemy>=2.0.46", "tomli>=2.4.0", "uvicorn>=0.37.0", + "websockets>=16.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 74d3f7a..da4a989 100644 --- a/uv.lock +++ b/uv.lock @@ -430,6 +430,7 @@ dependencies = [ { name = "fastapi" }, { name = "greenlet" }, { name = "httpx" }, + { name = "itsdangerous" }, { name = "livekit" }, { name = "loguru" }, { name = "orjson" }, @@ -441,6 +442,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tomli" }, { name = "uvicorn" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -459,6 +461,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.119.0" }, { name = "greenlet", specifier = ">=3.3.1" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "livekit", specifier = ">=1.0.25" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.4" }, @@ -470,6 +473,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = ">=2.0.46" }, { name = "tomli", specifier = ">=2.4.0" }, { name = "uvicorn", specifier = ">=0.37.0" }, + { name = "websockets", specifier = ">=16.0" }, ] [package.metadata.requires-dev] @@ -521,6 +525,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "livekit" version = "1.0.25" @@ -1129,6 +1142,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "win32-setctime" version = "1.2.0"