feat: add Imagine feature with WebSocket support for real-time image generation

This commit is contained in:
Chenyme committed 2026-02-07 16:37:50 +08:00
1 parent 51944d122f
commit a84880c75b
12 files changed
+1952 -8

No files matched your search

+245 -1
View File
@@ -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)"""
+1 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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>
+679
View File
@@ -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;
}
}
+201
View File
@@ -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>
+768
View File
@@ -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');
}
});
}
})();
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2
View File
@@ -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]
Generated
+49
View File
@@ -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"