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