feat: add Imagine feature with WebSocket support for real-time image generation
This commit is contained in:
1 parent
51944d122f
commit
a84880c75b
12 files changed
+1952
-8
No files matched your search
+245
-1
@@ -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)"""
|
||||
|
||||
Vendored
+1
-1
@@ -198,7 +198,7 @@
|
||||
|
||||
<script src="/static/common/admin-auth.js?v=2"></script>
|
||||
<script src="/static/common/batch-sse.js?v=1"></script>
|
||||
<script src="/static/common/header.js?v=3"></script>
|
||||
<script src="/static/common/header.js?v=4"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/cache/cache.js?v=8"></script>
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<button type="button" class="nav-link nav-group-trigger text-sm" aria-haspopup="true"
|
||||
aria-expanded="false">更多功能</button>
|
||||
aria-expanded="false">功能玩法</button>
|
||||
<div class="nav-group-menu">
|
||||
<a href="/admin/voice" class="nav-link text-sm" data-nav="/admin/voice">语音会话</a>
|
||||
<a href="/admin/imagine" class="nav-link text-sm" data-nav="/admin/imagine">Imagine 瀑布流</a>
|
||||
<a href="/admin/voice" class="nav-link text-sm" data-nav="/admin/voice">Voice Live 陪聊</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div id="app-footer"></div>
|
||||
|
||||
<script src="/static/common/admin-auth.js?v=2"></script>
|
||||
<script src="/static/common/header.js?v=3"></script>
|
||||
<script src="/static/common/header.js?v=4"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/config/config.js?v=14"></script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grok2API - Imagine 瀑布流</title>
|
||||
<link rel="icon" href="/static/favicon/favicon.ico">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
|
||||
<link href="/static/common/common.css?v=3" rel="stylesheet">
|
||||
<link href="/static/imagine/imagine.css?v=2" rel="stylesheet">
|
||||
<link href="/static/common/toast.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
<div id="app-header"></div>
|
||||
|
||||
<main class="flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">Imagine 瀑布流</h2>
|
||||
<p class="text-[var(--accents-4)] mt-1 text-sm">通过 WebSocket 持续生成图片,实时展示 base64 瀑布流。</p>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[var(--border)]"></div>
|
||||
|
||||
<div class="imagine-top-grid">
|
||||
<div class="imagine-card imagine-card-collapsible">
|
||||
<div class="card-title">生成设置</div>
|
||||
<div class="card-content" id="settingsContent">
|
||||
<div class="settings-layout">
|
||||
<div class="settings-top">
|
||||
<div class="settings-prompt">
|
||||
<label class="field-label" for="promptInput">提示词</label>
|
||||
<textarea id="promptInput" class="geist-input imagine-textarea" placeholder="描述你想要的画面,例如:未来城市霓虹雨夜,广角摄影"></textarea>
|
||||
</div>
|
||||
<div class="settings-options">
|
||||
<div>
|
||||
<label class="field-label" for="ratioSelect">宽高比</label>
|
||||
<select id="ratioSelect" class="geist-input">
|
||||
<option value="2:3" selected>2:3 竖图</option>
|
||||
<option value="1:1">1:1 方图</option>
|
||||
<option value="3:2">3:2 横图</option>
|
||||
<option value="16:9">16:9 宽屏</option>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="concurrentSelect">并发数量</label>
|
||||
<select id="concurrentSelect" class="geist-input">
|
||||
<option value="1" selected>1 个任务</option>
|
||||
<option value="2">2 个任务</option>
|
||||
<option value="3">3 个任务</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-bottom-row">
|
||||
<div class="settings-toggles">
|
||||
<div class="settings-toggle">
|
||||
<label class="field-label" for="autoScrollToggle">自动跟随</label>
|
||||
<label class="toggle">
|
||||
<input id="autoScrollToggle" type="checkbox" checked>
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-label">滚动到最新</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-toggle">
|
||||
<label class="field-label" for="autoDownloadToggle">自动下载</label>
|
||||
<label class="toggle">
|
||||
<input id="autoDownloadToggle" type="checkbox">
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-label">自动保存图片</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-folder">
|
||||
<label class="field-label">保存位置</label>
|
||||
<button id="selectFolderBtn" class="geist-button-outline folder-select-btn" disabled>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span id="folderPath">浏览器默认位置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="imagine-card imagine-card-collapsible">
|
||||
<div class="status-header-row" id="statusToggle">
|
||||
<div class="card-title">连接状态</div>
|
||||
<span id="statusText" class="status-text">未连接</span>
|
||||
<svg class="collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content" id="statusContent">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">图片数量</div>
|
||||
<div id="countValue" class="meta-value">0</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">活跃任务</div>
|
||||
<div id="activeValue" class="meta-value">0</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">平均耗时</div>
|
||||
<div id="latencyValue" class="meta-value">-</div>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<div class="meta-label">最后错误</div>
|
||||
<div id="errorValue" class="meta-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="imagine-card" style="overflow: hidden;">
|
||||
<div class="waterfall-header">
|
||||
<div class="card-title">瀑布流</div>
|
||||
<div class="waterfall-actions">
|
||||
<div id="selectionToolbar" class="selection-toolbar hidden">
|
||||
<button id="toggleSelectAllBtn" class="selection-btn">全选</button>
|
||||
<button id="downloadSelectedBtn" class="selection-btn selection-btn-primary">下载选中 (<span id="selectedCount">0</span>)</button>
|
||||
</div>
|
||||
<button id="batchDownloadBtn" class="batch-download-btn" title="批量下载">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="emptyState" class="imagine-empty">等待连接中...</div>
|
||||
<div id="waterfall" class="waterfall"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 图片放大预览 -->
|
||||
<div id="lightbox" class="image-lightbox">
|
||||
<button class="lightbox-close" id="closeLightbox">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="lightbox-nav lightbox-prev" id="lightboxPrev">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="lightbox-nav lightbox-next" id="lightboxNext">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<img id="lightboxImg" src="" alt="">
|
||||
</div>
|
||||
|
||||
<div id="floatingActions" class="floating-actions">
|
||||
<button id="startBtn" class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
开始
|
||||
</button>
|
||||
<button id="stopBtn" class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-gray-100 hidden">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="5" width="14" height="14" />
|
||||
</svg>
|
||||
停止
|
||||
</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button id="clearBtn" class="geist-button-outline text-xs px-3 gap-1 border-0 hover:bg-red-100">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="app-footer"></div>
|
||||
|
||||
<script src="/static/common/admin-auth.js?v=2"></script>
|
||||
<script src="/static/common/header.js?v=4"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="/static/imagine/imagine.js?v=3"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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 = `下载选中 (<span id="selectedCount">${selectedImages.size}</span>)`;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -294,7 +294,7 @@
|
||||
<script src="/static/common/draggable.js"></script>
|
||||
<script src="/static/common/admin-auth.js?v=2"></script>
|
||||
<script src="/static/common/batch-sse.js?v=1"></script>
|
||||
<script src="/static/common/header.js?v=3"></script>
|
||||
<script src="/static/common/header.js?v=4"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/token/token.js?v=19"></script>
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<div id="app-footer"></div>
|
||||
|
||||
<script src="/static/common/admin-auth.js?v=2"></script>
|
||||
<script src="/static/common/header.js?v=3"></script>
|
||||
<script src="/static/common/header.js?v=4"></script>
|
||||
<script src="/static/common/footer.js?v=1"></script>
|
||||
<script src="/static/common/toast.js"></script>
|
||||
<script src="/static/voice/voice.js?v=8"></script>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user