From 928faabbac422fb7d715d3969ea239460619ddef Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:43:57 +0800 Subject: [PATCH] refactor: update configuration settings, enhance public API integration, and streamline service imports while removing deprecated files --- app/api/pages/__init__.py | 13 + app/api/pages/admin.py | 32 + app/api/pages/public.py | 44 ++ app/api/v1/pages.py | 94 --- app/api/v1/public/__init__.py | 2 + app/api/v1/public/imagine.py | 154 +++-- app/api/v1/public/video.py | 274 ++++++++ app/core/response_middleware.py | 14 + app/services/grok/defaults.py | 110 +-- app/services/grok/services/chat.py | 52 +- app/services/grok/services/image.py | 15 +- app/services/grok/services/video.py | 105 ++- app/services/reverse/__init__.py | 2 + app/services/reverse/media_post.py | 17 +- app/services/reverse/video_upscale.py | 109 +++ app/services/token/manager.py | 8 + app/static/{cache => admin/css}/cache.css | 0 app/static/{config => admin/css}/config.css | 12 + app/static/{token => admin/css}/token.css | 0 app/static/{cache => admin/js}/cache.js | 0 app/static/{config => admin/js}/config.js | 62 +- app/static/{login => admin/js}/login.js | 0 app/static/{token => admin/js}/token.js | 0 app/static/{cache => admin/pages}/cache.html | 22 +- .../{config => admin/pages}/config.html | 18 +- app/static/{login => admin/pages}/login.html | 16 +- app/static/{token => admin/pages}/token.html | 22 +- app/static/common/{ => css}/common.css | 0 app/static/{login => common/css}/login.css | 0 app/static/common/{ => css}/toast.css | 0 app/static/common/{ => html}/footer.html | 0 app/static/common/{ => html}/header.html | 14 +- .../common/{ => html}/public-header.html | 4 +- .../{ => common/img}/favicon/favicon.ico | Bin app/static/common/{ => js}/admin-auth.js | 0 app/static/common/{ => js}/batch-sse.js | 0 app/static/common/{ => js}/draggable.js | 0 app/static/common/{ => js}/footer.js | 2 +- app/static/common/{ => js}/header.js | 2 +- app/static/common/{ => js}/public-header.js | 14 +- app/static/common/{ => js}/toast.js | 0 .../{imagine => public/css}/imagine.css | 199 +++++- app/static/public/css/video.css | 482 +++++++++++++ app/static/{voice => public/css}/voice.css | 0 app/static/{imagine => public/js}/imagine.js | 235 ++++++- app/static/public/{ => js}/login.js | 0 app/static/public/js/video.js | 640 ++++++++++++++++++ app/static/{voice => public/js}/voice.js | 0 .../{imagine => public/pages}/imagine.html | 125 ++-- app/static/public/{ => pages}/login.html | 16 +- app/static/public/pages/video.html | 168 +++++ app/static/{voice => public/pages}/voice.html | 18 +- config.defaults.toml | 38 +- data/config.toml | 50 +- main.py | 2 +- 55 files changed, 2700 insertions(+), 506 deletions(-) create mode 100644 app/api/pages/__init__.py create mode 100644 app/api/pages/admin.py create mode 100644 app/api/pages/public.py delete mode 100644 app/api/v1/pages.py create mode 100644 app/api/v1/public/video.py create mode 100644 app/services/reverse/video_upscale.py rename app/static/{cache => admin/css}/cache.css (100%) rename app/static/{config => admin/css}/config.css (80%) rename app/static/{token => admin/css}/token.css (100%) rename app/static/{cache => admin/js}/cache.js (100%) rename app/static/{config => admin/js}/config.js (85%) rename app/static/{login => admin/js}/login.js (100%) rename app/static/{token => admin/js}/token.js (100%) rename app/static/{cache => admin/pages}/cache.html (93%) rename app/static/{config => admin/pages}/config.html (75%) rename app/static/{login => admin/pages}/login.html (82%) rename app/static/{token => admin/pages}/token.html (95%) rename app/static/common/{ => css}/common.css (100%) rename app/static/{login => common/css}/login.css (100%) rename app/static/common/{ => css}/toast.css (100%) rename app/static/common/{ => html}/footer.html (100%) rename app/static/common/{ => html}/header.html (73%) rename app/static/common/{ => html}/public-header.html (89%) rename app/static/{ => common/img}/favicon/favicon.ico (100%) rename app/static/common/{ => js}/admin-auth.js (100%) rename app/static/common/{ => js}/batch-sse.js (100%) rename app/static/common/{ => js}/draggable.js (100%) rename app/static/common/{ => js}/footer.js (85%) rename app/static/common/{ => js}/header.js (93%) rename app/static/common/{ => js}/public-header.js (59%) rename app/static/common/{ => js}/toast.js (100%) rename app/static/{imagine => public/css}/imagine.css (80%) create mode 100644 app/static/public/css/video.css rename app/static/{voice => public/css}/voice.css (100%) rename app/static/{imagine => public/js}/imagine.js (79%) rename app/static/public/{ => js}/login.js (100%) create mode 100644 app/static/public/js/video.js rename app/static/{voice => public/js}/voice.js (100%) rename app/static/{imagine => public/pages}/imagine.html (63%) rename app/static/public/{ => pages}/login.html (83%) create mode 100644 app/static/public/pages/video.html rename app/static/{voice => public/pages}/voice.html (92%) diff --git a/app/api/pages/__init__.py b/app/api/pages/__init__.py new file mode 100644 index 0000000..6aada6e --- /dev/null +++ b/app/api/pages/__init__.py @@ -0,0 +1,13 @@ +"""UI pages router.""" + +from fastapi import APIRouter + +from app.api.pages.admin import router as admin_router +from app.api.pages.public import router as public_router + +router = APIRouter() + +router.include_router(public_router) +router.include_router(admin_router) + +__all__ = ["router"] diff --git a/app/api/pages/admin.py b/app/api/pages/admin.py new file mode 100644 index 0000000..bb581e8 --- /dev/null +++ b/app/api/pages/admin.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse, RedirectResponse + +router = APIRouter() +STATIC_DIR = Path(__file__).resolve().parents[2] / "static" + + +@router.get("/admin", include_in_schema=False) +async def admin_root(): + return RedirectResponse(url="/admin/login") + + +@router.get("/admin/login", include_in_schema=False) +async def admin_login(): + return FileResponse(STATIC_DIR / "admin/pages/login.html") + + +@router.get("/admin/config", include_in_schema=False) +async def admin_config(): + return FileResponse(STATIC_DIR / "admin/pages/config.html") + + +@router.get("/admin/cache", include_in_schema=False) +async def admin_cache(): + return FileResponse(STATIC_DIR / "admin/pages/cache.html") + + +@router.get("/admin/token", include_in_schema=False) +async def admin_token(): + return FileResponse(STATIC_DIR / "admin/pages/token.html") diff --git a/app/api/pages/public.py b/app/api/pages/public.py new file mode 100644 index 0000000..0792df9 --- /dev/null +++ b/app/api/pages/public.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, RedirectResponse + +from app.core.auth import is_public_enabled + +router = APIRouter() +STATIC_DIR = Path(__file__).resolve().parents[2] / "static" + + +@router.get("/", include_in_schema=False) +async def root(): + if is_public_enabled(): + return RedirectResponse(url="/login") + return RedirectResponse(url="/admin/login") + + +@router.get("/login", include_in_schema=False) +async def public_login(): + if not is_public_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(STATIC_DIR / "public/pages/login.html") + + +@router.get("/imagine", include_in_schema=False) +async def public_imagine(): + if not is_public_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(STATIC_DIR / "public/pages/imagine.html") + + +@router.get("/voice", include_in_schema=False) +async def public_voice(): + if not is_public_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(STATIC_DIR / "public/pages/voice.html") + + +@router.get("/video", include_in_schema=False) +async def public_video(): + if not is_public_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(STATIC_DIR / "public/pages/video.html") diff --git a/app/api/v1/pages.py b/app/api/v1/pages.py deleted file mode 100644 index cd9472c..0000000 --- a/app/api/v1/pages.py +++ /dev/null @@ -1,94 +0,0 @@ -from pathlib import Path - -import aiofiles -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse, RedirectResponse - -from app.core.auth import is_public_enabled - -router = APIRouter() -TEMPLATE_DIR = Path(__file__).resolve().parents[2] / "static" - - -async def render_template(filename: str) -> HTMLResponse: - """渲染指定模板""" - template_path = TEMPLATE_DIR / filename - if not template_path.exists(): - return HTMLResponse(f"Template {filename} not found.", status_code=404) - - async with aiofiles.open(template_path, "r", encoding="utf-8") as f: - content = await f.read() - return HTMLResponse(content) - -@router.get("/", include_in_schema=False) -async def root_redirect(): - if is_public_enabled(): - return RedirectResponse(url="/login") - return RedirectResponse(url="/admin/login") - - -@router.get("/login", response_class=HTMLResponse, include_in_schema=False) -async def public_login_page(): - """Public 登录页""" - if not is_public_enabled(): - raise HTTPException(status_code=404, detail="Not Found") - return await render_template("public/login.html") - - -@router.get("/imagine", response_class=HTMLResponse, include_in_schema=False) -async def public_imagine_page(): - """Imagine 图片瀑布流""" - if not is_public_enabled(): - raise HTTPException(status_code=404, detail="Not Found") - return await render_template("imagine/imagine.html") - - -@router.get("/voice", response_class=HTMLResponse, include_in_schema=False) -async def public_voice_page(): - """Voice Live 调试页""" - if not is_public_enabled(): - raise HTTPException(status_code=404, detail="Not Found") - return await render_template("voice/voice.html") - - -@router.get("/admin", include_in_schema=False) -async def admin_root_redirect(): - return RedirectResponse(url="/admin/login") - - -@router.get("/admin/login", response_class=HTMLResponse, include_in_schema=False) -async def admin_login_page(): - """管理后台登录页""" - return await render_template("login/login.html") - - -@router.get("/admin/config", response_class=HTMLResponse, include_in_schema=False) -async def admin_config_page(): - """配置管理页""" - return await render_template("config/config.html") - - -@router.get("/admin/token", response_class=HTMLResponse, include_in_schema=False) -async def admin_token_page(): - """Token 管理页""" - return await render_template("token/token.html") - - -@router.get("/admin/voice", include_in_schema=False) -async def admin_voice_redirect(): - if not is_public_enabled(): - raise HTTPException(status_code=404, detail="Not Found") - return RedirectResponse(url="/voice") - - -@router.get("/admin/imagine", include_in_schema=False) -async def admin_imagine_redirect(): - if not is_public_enabled(): - raise HTTPException(status_code=404, detail="Not Found") - return RedirectResponse(url="/imagine") - - -@router.get("/admin/cache", response_class=HTMLResponse, include_in_schema=False) -async def admin_cache_page(): - """缓存管理页""" - return await render_template("cache/cache.html") diff --git a/app/api/v1/public/__init__.py b/app/api/v1/public/__init__.py index 0d4ab69..984bf0d 100644 --- a/app/api/v1/public/__init__.py +++ b/app/api/v1/public/__init__.py @@ -3,11 +3,13 @@ from fastapi import APIRouter from app.api.v1.public.imagine import router as imagine_router +from app.api.v1.public.video import router as video_router from app.api.v1.public.voice import router as voice_router router = APIRouter() router.include_router(imagine_router) +router.include_router(video_router) router.include_router(voice_router) __all__ = ["router"] diff --git a/app/api/v1/public/imagine.py b/app/api/v1/public/imagine.py index 5e71436..83f59c3 100644 --- a/app/api/v1/public/imagine.py +++ b/app/api/v1/public/imagine.py @@ -1,7 +1,7 @@ import asyncio import time import uuid -from typing import Optional, List +from typing import Optional, List, Dict, Any import orjson from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect @@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel from app.core.auth import verify_public_key, get_public_api_key, is_public_enabled +from app.core.config import get_config from app.core.logger import logger from app.api.v1.image import resolve_aspect_ratio from app.services.grok.services.image import ImageGenerationService @@ -32,7 +33,35 @@ async def _clean_sessions(now: float) -> None: _IMAGINE_SESSIONS.pop(key, None) -async def _new_session(prompt: str, aspect_ratio: str) -> str: +def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]: + if not chunk: + return None + event = None + data_lines: List[str] = [] + for raw in str(chunk).splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith("event:"): + event = line[6:].strip() + continue + if line.startswith("data:"): + data_lines.append(line[5:].strip()) + if not data_lines: + return None + data_str = "\n".join(data_lines) + if data_str == "[DONE]": + return None + try: + payload = orjson.loads(data_str) + except orjson.JSONDecodeError: + return None + if event and isinstance(payload, dict) and "type" not in payload: + payload["type"] = event + return payload + + +async def _new_session(prompt: str, aspect_ratio: str, nsfw: Optional[bool]) -> str: task_id = uuid.uuid4().hex now = time.time() async with _IMAGINE_SESSIONS_LOCK: @@ -40,6 +69,7 @@ async def _new_session(prompt: str, aspect_ratio: str) -> str: _IMAGINE_SESSIONS[task_id] = { "prompt": prompt, "aspect_ratio": aspect_ratio, + "nsfw": nsfw, "created_at": now, } return task_id @@ -126,7 +156,7 @@ async def public_imagine_ws(websocket: WebSocket): run_task = None stop_event.clear() - async def _run(prompt: str, aspect_ratio: str): + async def _run(prompt: str, aspect_ratio: str, nsfw: Optional[bool]): model_id = "grok-imagine-1.0" model_info = ModelService.get(model_id) if not model_info or not model_info.is_image: @@ -140,7 +170,6 @@ async def public_imagine_ws(websocket: WebSocket): return token_mgr = await get_token_manager() - sequence = 0 run_id = uuid.uuid4().hex await _send( @@ -175,7 +204,6 @@ async def public_imagine_ws(websocket: WebSocket): await asyncio.sleep(2) continue - start_at = time.time() result = await ImageGenerationService().generate( token_mgr=token_mgr, token=token, @@ -185,33 +213,38 @@ async def public_imagine_ws(websocket: WebSocket): response_format="b64_json", size="1024x1024", aspect_ratio=aspect_ratio, - stream=False, + stream=True, + enable_nsfw=nsfw, ) - elapsed_ms = int((time.time() - start_at) * 1000) - - images = [img for img in result.data if img and img != "error"] - if images: - for img_b64 in images: - sequence += 1 + if result.stream: + async for chunk in result.data: + payload = _parse_sse_chunk(chunk) + if not payload: + continue + if isinstance(payload, dict): + payload.setdefault("run_id", run_id) + await _send(payload) + else: + images = [img for img in result.data if img and img != "error"] + if images: + for img_b64 in images: + await _send( + { + "type": "image", + "b64_json": img_b64, + "created_at": int(time.time() * 1000), + "aspect_ratio": aspect_ratio, + "run_id": run_id, + } + ) + else: 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, + "type": "error", + "message": "Image generation returned empty data.", + "code": "empty_image", } ) - else: - await _send( - { - "type": "error", - "message": "Image generation returned empty data.", - "code": "empty_image", - } - ) except asyncio.CancelledError: break @@ -262,8 +295,11 @@ async def public_imagine_ws(websocket: WebSocket): aspect_ratio = resolve_aspect_ratio( str(payload.get("aspect_ratio") or "2:3").strip() or "2:3" ) + nsfw = payload.get("nsfw") + if nsfw is not None: + nsfw = bool(nsfw) await _stop_run() - run_task = asyncio.create_task(_run(prompt, aspect_ratio)) + run_task = asyncio.create_task(_run(prompt, aspect_ratio, nsfw)) elif action == "stop": await _stop_run() else: @@ -319,12 +355,16 @@ async def public_imagine_sse( if session: prompt = str(session.get("prompt") or "").strip() ratio = str(session.get("aspect_ratio") or "2:3").strip() or "2:3" + nsfw = session.get("nsfw") else: prompt = (prompt or "").strip() if not prompt: raise HTTPException(status_code=400, detail="Prompt cannot be empty") ratio = str(aspect_ratio or "2:3").strip() or "2:3" ratio = resolve_aspect_ratio(ratio) + nsfw = request.query_params.get("nsfw") + if nsfw is not None: + nsfw = str(nsfw).lower() in ("1", "true", "yes", "on") async def event_stream(): try: @@ -369,7 +409,6 @@ async def public_imagine_sse( await asyncio.sleep(2) continue - start_at = time.time() result = await ImageGenerationService().generate( token_mgr=token_mgr, token=token, @@ -379,28 +418,35 @@ async def public_imagine_sse( response_format="b64_json", size="1024x1024", aspect_ratio=ratio, - stream=False, + stream=True, + enable_nsfw=nsfw, ) - elapsed_ms = int((time.time() - start_at) * 1000) - - images = [img for img in result.data if img and img != "error"] - if images: - for img_b64 in images: - sequence += 1 - payload = { - "type": "image", - "b64_json": img_b64, - "sequence": sequence, - "created_at": int(time.time() * 1000), - "elapsed_ms": elapsed_ms, - "aspect_ratio": ratio, - "run_id": run_id, - } + if result.stream: + async for chunk in result.data: + payload = _parse_sse_chunk(chunk) + if not payload: + continue + if isinstance(payload, dict): + payload.setdefault("run_id", run_id) yield f"data: {orjson.dumps(payload).decode()}\n\n" else: - yield ( - f"data: {orjson.dumps({'type': 'error', 'message': 'Image generation returned empty data.', 'code': 'empty_image'}).decode()}\n\n" - ) + images = [img for img in result.data if img and img != "error"] + if images: + for img_b64 in images: + sequence += 1 + payload = { + "type": "image", + "b64_json": img_b64, + "sequence": sequence, + "created_at": int(time.time() * 1000), + "aspect_ratio": ratio, + "run_id": run_id, + } + yield f"data: {orjson.dumps(payload).decode()}\n\n" + else: + yield ( + f"data: {orjson.dumps({'type': 'error', 'message': 'Image generation returned empty data.', 'code': 'empty_image'}).decode()}\n\n" + ) except asyncio.CancelledError: break except Exception as e: @@ -424,9 +470,19 @@ async def public_imagine_sse( ) +@router.get("/imagine/config") +async def public_imagine_config(): + return { + "final_min_bytes": int(get_config("image.final_min_bytes") or 0), + "medium_min_bytes": int(get_config("image.medium_min_bytes") or 0), + "nsfw": bool(get_config("image.nsfw")), + } + + class ImagineStartRequest(BaseModel): prompt: str aspect_ratio: Optional[str] = "2:3" + nsfw: Optional[bool] = None @router.post("/imagine/start", dependencies=[Depends(verify_public_key)]) @@ -435,7 +491,7 @@ async def public_imagine_start(data: ImagineStartRequest): if not prompt: raise HTTPException(status_code=400, detail="Prompt cannot be empty") ratio = resolve_aspect_ratio(str(data.aspect_ratio or "2:3").strip() or "2:3") - task_id = await _new_session(prompt, ratio) + task_id = await _new_session(prompt, ratio, data.nsfw) return {"task_id": task_id, "aspect_ratio": ratio} diff --git a/app/api/v1/public/video.py b/app/api/v1/public/video.py new file mode 100644 index 0000000..c88182c --- /dev/null +++ b/app/api/v1/public/video.py @@ -0,0 +1,274 @@ +import asyncio +import time +import uuid +from typing import Optional, List, Dict, Any + +import orjson +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.core.auth import verify_public_key +from app.core.logger import logger +from app.services.grok.services.video import VideoService +from app.services.grok.services.model import ModelService + +router = APIRouter() + +VIDEO_SESSION_TTL = 600 +_VIDEO_SESSIONS: dict[str, dict] = {} +_VIDEO_SESSIONS_LOCK = asyncio.Lock() + +_VIDEO_RATIO_MAP = { + "1280x720": "16:9", + "720x1280": "9:16", + "1792x1024": "3:2", + "1024x1792": "2:3", + "1024x1024": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "3:2": "3:2", + "2:3": "2:3", + "1:1": "1:1", +} + + +async def _clean_sessions(now: float) -> None: + expired = [ + key + for key, info in _VIDEO_SESSIONS.items() + if now - float(info.get("created_at") or 0) > VIDEO_SESSION_TTL + ] + for key in expired: + _VIDEO_SESSIONS.pop(key, None) + + +async def _new_session( + prompt: str, + aspect_ratio: str, + video_length: int, + resolution_name: str, + preset: str, + image_url: Optional[str], + reasoning_effort: Optional[str], +) -> str: + task_id = uuid.uuid4().hex + now = time.time() + async with _VIDEO_SESSIONS_LOCK: + await _clean_sessions(now) + _VIDEO_SESSIONS[task_id] = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "video_length": video_length, + "resolution_name": resolution_name, + "preset": preset, + "image_url": image_url, + "reasoning_effort": reasoning_effort, + "created_at": now, + } + return task_id + + +async def _get_session(task_id: str) -> Optional[dict]: + if not task_id: + return None + now = time.time() + async with _VIDEO_SESSIONS_LOCK: + await _clean_sessions(now) + info = _VIDEO_SESSIONS.get(task_id) + if not info: + return None + created_at = float(info.get("created_at") or 0) + if now - created_at > VIDEO_SESSION_TTL: + _VIDEO_SESSIONS.pop(task_id, None) + return None + return dict(info) + + +async def _drop_session(task_id: str) -> None: + if not task_id: + return + async with _VIDEO_SESSIONS_LOCK: + _VIDEO_SESSIONS.pop(task_id, None) + + +async def _drop_sessions(task_ids: List[str]) -> int: + if not task_ids: + return 0 + removed = 0 + async with _VIDEO_SESSIONS_LOCK: + for task_id in task_ids: + if task_id and task_id in _VIDEO_SESSIONS: + _VIDEO_SESSIONS.pop(task_id, None) + removed += 1 + return removed + + +def _normalize_ratio(value: Optional[str]) -> str: + raw = (value or "").strip() + return _VIDEO_RATIO_MAP.get(raw, "") + + +def _validate_image_url(image_url: str) -> None: + value = (image_url or "").strip() + if not value: + return + if value.startswith("data:"): + return + if value.startswith("http://") or value.startswith("https://"): + return + raise HTTPException( + status_code=400, + detail="image_url must be a URL or data URI (data:;base64,...)", + ) + + +class VideoStartRequest(BaseModel): + prompt: str + aspect_ratio: Optional[str] = "3:2" + video_length: Optional[int] = 6 + resolution_name: Optional[str] = "480p" + preset: Optional[str] = "normal" + image_url: Optional[str] = None + reasoning_effort: Optional[str] = None + + +@router.post("/video/start", dependencies=[Depends(verify_public_key)]) +async def public_video_start(data: VideoStartRequest): + prompt = (data.prompt or "").strip() + if not prompt: + raise HTTPException(status_code=400, detail="Prompt cannot be empty") + + aspect_ratio = _normalize_ratio(data.aspect_ratio) + if not aspect_ratio: + raise HTTPException( + status_code=400, + detail="aspect_ratio must be one of ['16:9','9:16','3:2','2:3','1:1']", + ) + + video_length = int(data.video_length or 6) + if video_length not in (6, 10, 15): + raise HTTPException( + status_code=400, detail="video_length must be 6, 10, or 15 seconds" + ) + + resolution_name = str(data.resolution_name or "480p") + if resolution_name not in ("480p", "720p"): + raise HTTPException( + status_code=400, + detail="resolution_name must be one of ['480p','720p']", + ) + + preset = str(data.preset or "normal") + if preset not in ("fun", "normal", "spicy", "custom"): + raise HTTPException( + status_code=400, + detail="preset must be one of ['fun','normal','spicy','custom']", + ) + + image_url = (data.image_url or "").strip() or None + if image_url: + _validate_image_url(image_url) + + reasoning_effort = (data.reasoning_effort or "").strip() or None + if reasoning_effort: + allowed = {"none", "minimal", "low", "medium", "high", "xhigh"} + if reasoning_effort not in allowed: + raise HTTPException( + status_code=400, + detail=f"reasoning_effort must be one of {sorted(allowed)}", + ) + + task_id = await _new_session( + prompt, + aspect_ratio, + video_length, + resolution_name, + preset, + image_url, + reasoning_effort, + ) + return {"task_id": task_id, "aspect_ratio": aspect_ratio} + + +@router.get("/video/sse") +async def public_video_sse(request: Request, task_id: str = Query("")): + session = await _get_session(task_id) + if not session: + raise HTTPException(status_code=404, detail="Task not found") + + prompt = str(session.get("prompt") or "").strip() + aspect_ratio = str(session.get("aspect_ratio") or "3:2") + video_length = int(session.get("video_length") or 6) + resolution_name = str(session.get("resolution_name") or "480p") + preset = str(session.get("preset") or "normal") + image_url = session.get("image_url") + reasoning_effort = session.get("reasoning_effort") + + async def event_stream(): + try: + model_id = "grok-imagine-1.0-video" + model_info = ModelService.get(model_id) + if not model_info or not model_info.is_video: + payload = { + "error": "Video model is not available.", + "code": "model_not_supported", + } + yield f"data: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + return + + if image_url: + messages: List[Dict[str, Any]] = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ] + else: + messages = [{"role": "user", "content": prompt}] + + stream = await VideoService.completions( + model_id, + messages, + stream=True, + reasoning_effort=reasoning_effort, + aspect_ratio=aspect_ratio, + video_length=video_length, + resolution=resolution_name, + preset=preset, + ) + + async for chunk in stream: + if await request.is_disconnected(): + break + yield chunk + except Exception as e: + logger.warning(f"Public video SSE error: {e}") + payload = {"error": str(e), "code": "internal_error"} + yield f"data: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + finally: + await _drop_session(task_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +class VideoStopRequest(BaseModel): + task_ids: List[str] + + +@router.post("/video/stop", dependencies=[Depends(verify_public_key)]) +async def public_video_stop(data: VideoStopRequest): + removed = await _drop_sessions(data.task_ids or []) + return {"status": "success", "removed": removed} + + +__all__ = ["router"] diff --git a/app/core/response_middleware.py b/app/core/response_middleware.py index 2cfa8b3..4c0a07e 100644 --- a/app/core/response_middleware.py +++ b/app/core/response_middleware.py @@ -25,6 +25,20 @@ class ResponseLoggerMiddleware(BaseHTTPMiddleware): request.state.trace_id = trace_id start_time = time.time() + path = request.url.path + + if path.startswith("/static/") or path in ( + "/", + "/login", + "/imagine", + "/voice", + "/admin", + "/admin/login", + "/admin/config", + "/admin/cache", + "/admin/token", + ): + return await call_next(request) # 记录请求信息 logger.info( diff --git a/app/services/grok/defaults.py b/app/services/grok/defaults.py index 06a4158..d7af7eb 100644 --- a/app/services/grok/defaults.py +++ b/app/services/grok/defaults.py @@ -1,101 +1,33 @@ """ Grok 服务默认配置 -此文件定义所有 Grok 相关服务的默认值,会在应用启动时注册到配置系统中。 +此文件读取 config.defaults.toml,作为 Grok 服务的默认值来源。 """ -# Grok 服务默认配置 -GROK_DEFAULTS = { - "app": { - "app_url": "", - "app_key": "grok2api", - "api_key": "", - "public_key": "", - "public_enabled": False, - "image_format": "url", - "video_format": "html", - "temporary": True, - "disable_memory": True, - "stream": True, - "thinking": True, - "dynamic_statsig": True, - "filter_tags": ["xaiartifact", "xai:tool_usage_card", "grok:render"], - }, - "proxy": { - "base_proxy_url": "", - "asset_proxy_url": "", - "cf_clearance": "", - "browser": "chrome136", - "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - }, - "voice": { - "timeout": 120, - }, - "chat": { - "concurrent": 10, - "timeout": 60, - "stream_timeout": 60, - }, - "video": { - "concurrent": 10, - "timeout": 60, - "stream_timeout": 60, - }, - "retry": { - "max_retry": 3, - "retry_status_codes": [401, 429, 403], - "retry_backoff_base": 0.5, - "retry_backoff_factor": 2.0, - "retry_backoff_max": 30.0, - "retry_budget": 90.0, - }, - "image": { - "timeout": 120, - "stream_timeout": 120, - "final_timeout": 15, - "nsfw": True, - "medium_min_bytes": 30000, - "final_min_bytes": 100000, - }, - "token": { - "auto_refresh": True, - "refresh_interval_hours": 8, - "super_refresh_interval_hours": 2, - "fail_threshold": 5, - "save_delay_ms": 500, - "reload_interval_sec": 30, - }, - "cache": { - "enable_auto_clean": True, - "limit_mb": 1024, - }, - "asset": { - "upload_concurrent": 30, - "upload_timeout": 60, - "download_concurrent": 30, - "download_timeout": 60, - "list_concurrent": 10, - "list_timeout": 60, - "list_batch_size": 10, - "delete_concurrent": 10, - "delete_timeout": 60, - "delete_batch_size": 10, - }, - "nsfw": { - "concurrent": 10, - "batch_size": 50, - "timeout": 60, - }, - "usage": { - "concurrent": 10, - "batch_size": 50, - "timeout": 60, - }, -} +from pathlib import Path +import tomllib + +from app.core.logger import logger + +DEFAULTS_FILE = Path(__file__).resolve().parent.parent.parent.parent / "config.defaults.toml" + +# Grok 服务默认配置(运行时从 config.defaults.toml 读取并缓存) +GROK_DEFAULTS: dict = {} def get_grok_defaults(): """获取 Grok 默认配置""" + global GROK_DEFAULTS + if GROK_DEFAULTS: + return GROK_DEFAULTS + if not DEFAULTS_FILE.exists(): + logger.warning(f"Defaults file not found: {DEFAULTS_FILE}") + return GROK_DEFAULTS + try: + with DEFAULTS_FILE.open("rb") as f: + GROK_DEFAULTS = tomllib.load(f) + except Exception as e: + logger.warning(f"Failed to load defaults from {DEFAULTS_FILE}: {e}") return GROK_DEFAULTS diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index 0d20327..260bb43 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -341,59 +341,19 @@ class StreamProcessor(proc_base.BaseProcessor): self.think_opened: bool = False self.role_sent: bool = False self.filter_tags = get_config("app.filter_tags") - self._tag_buffer: str = "" - self._in_filter_tag: bool = False self.show_think = bool(show_think) def _filter_token(self, token: str) -> str: - """Filter special tags (supports cross-token tag filtering).""" - if not self.filter_tags: + """Filter special tags in current token only.""" + if not self.filter_tags or not token: return token - result = [] - i = 0 - while i < len(token): - char = token[i] + for tag in self.filter_tags: + if f"<{tag}" in token or f"": - if "/>" in self._tag_buffer: - self._in_filter_tag = False - self._tag_buffer = "" - else: - for tag in self.filter_tags: - if f"" in self._tag_buffer: - self._in_filter_tag = False - self._tag_buffer = "" - break - i += 1 - continue - - if char == "<": - remaining = token[i:] - tag_started = False - for tag in self.filter_tags: - if remaining.startswith(f"<{tag}"): - tag_started = True - break - if len(remaining) < len(tag) + 1: - for j in range(1, len(remaining) + 1): - if f"<{tag}".startswith(remaining[:j]): - tag_started = True - break - - if tag_started: - self._in_filter_tag = True - self._tag_buffer = char - i += 1 - continue - - result.append(char) - i += 1 - - return "".join(result) + return token def _sse(self, content: str = "", role: str = None, finish: str = None) -> str: """Build SSE response.""" diff --git a/app/services/grok/services/image.py b/app/services/grok/services/image.py index 7db7bf9..75e2698 100644 --- a/app/services/grok/services/image.py +++ b/app/services/grok/services/image.py @@ -48,6 +48,7 @@ class ImageGenerationService: size: str, aspect_ratio: str, stream: bool, + enable_nsfw: Optional[bool] = None, ) -> ImageGenerationResult: max_token_retries = int(get_config("retry.max_retry")) tried_tokens: set[str] = set() @@ -83,6 +84,7 @@ class ImageGenerationService: response_format=response_format, size=size, aspect_ratio=aspect_ratio, + enable_nsfw=enable_nsfw, ) async for chunk in result.data: yielded = True @@ -137,6 +139,7 @@ class ImageGenerationService: n=n, response_format=response_format, aspect_ratio=aspect_ratio, + enable_nsfw=enable_nsfw, ) except UpstreamException as e: last_error = e @@ -169,8 +172,10 @@ class ImageGenerationService: response_format: str, size: str, aspect_ratio: str, + enable_nsfw: Optional[bool] = None, ) -> ImageGenerationResult: - enable_nsfw = bool(get_config("image.nsfw")) + if enable_nsfw is None: + enable_nsfw = bool(get_config("image.nsfw")) upstream = image_service.stream( token=token, prompt=prompt, @@ -203,8 +208,10 @@ class ImageGenerationService: n: int, response_format: str, aspect_ratio: str, + enable_nsfw: Optional[bool] = None, ) -> ImageGenerationResult: - enable_nsfw = bool(get_config("image.nsfw")) + if enable_nsfw is None: + enable_nsfw = bool(get_config("image.nsfw")) all_images: List[str] = [] seen = set() expected_per_call = 6 @@ -498,6 +505,8 @@ class ImageWSStreamProcessor(ImageWSBaseProcessor): "size": self.size, "index": index, "partial_image_index": partial_index, + "image_id": image_id, + "stage": stage, }, ) @@ -550,6 +559,8 @@ class ImageWSStreamProcessor(ImageWSBaseProcessor): "created_at": int(time.time()), "size": self.size, "index": index, + "image_id": image_id, + "stage": "final", "usage": { "total_tokens": 0, "input_tokens": 0, diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py index 5ba6504..70f477e 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -4,6 +4,7 @@ Grok video generation service. import asyncio import uuid +import re from typing import Any, AsyncGenerator, AsyncIterable, Optional import orjson @@ -31,6 +32,8 @@ from app.services.grok.utils.process import ( from app.services.grok.utils.retry import rate_limited from app.services.reverse.app_chat import AppChatReverse from app.services.reverse.media_post import MediaPostReverse +from app.services.reverse.video_upscale import VideoUpscaleReverse +from app.services.token.manager import BASIC_POOL_NAME _VIDEO_SEMAPHORE = None _VIDEO_SEM_VALUE = 0 @@ -63,13 +66,17 @@ class VideoService: if media_type == "MEDIA_POST_TYPE_IMAGE" and not media_url: raise ValidationException("media_url is required for image posts") + prompt_value = prompt if media_type == "MEDIA_POST_TYPE_VIDEO" else "" + media_value = media_url or "" + async with AsyncSession() as session: async with _get_video_semaphore(): response = await MediaPostReverse.request( session, token, media_type, - media_url or "", + media_value, + prompt=prompt_value, ) post_id = response.json().get("post", {}).get("id", "") @@ -264,6 +271,8 @@ class VideoService: token = token_info.token if token.startswith("sso="): token = token[4:] + pool_name = token_mgr.get_pool_name_for_token(token) + should_upscale = resolution == "720p" and pool_name == BASIC_POOL_NAME try: # Handle image attachments. @@ -305,12 +314,19 @@ class VideoService: # Process response. if is_stream: - processor = VideoStreamProcessor(model, token, show_think) + processor = VideoStreamProcessor( + model, + token, + show_think, + upscale_on_finish=should_upscale, + ) return wrap_stream_with_usage( processor.process(response), token_mgr, token, model ) - result = await VideoCollectProcessor(model, token).process(response) + result = await VideoCollectProcessor( + model, token, upscale_on_finish=should_upscale + ).process(response) try: model_info = ModelService.get(model) effort = ( @@ -350,13 +366,53 @@ class VideoService: class VideoStreamProcessor(BaseProcessor): """Video stream response processor.""" - def __init__(self, model: str, token: str = "", show_think: bool = None): + def __init__( + self, + model: str, + token: str = "", + show_think: bool = None, + upscale_on_finish: bool = False, + ): super().__init__(model, token) self.response_id: Optional[str] = None self.think_opened: bool = False self.role_sent: bool = False self.show_think = bool(show_think) + self.upscale_on_finish = bool(upscale_on_finish) + + @staticmethod + def _extract_video_id(video_url: str) -> str: + if not video_url: + return "" + match = re.search(r"/generated/([0-9a-fA-F-]{32,36})/", video_url) + if match: + return match.group(1) + match = re.search(r"/([0-9a-fA-F-]{32,36})/generated_video", video_url) + if match: + return match.group(1) + return "" + + async def _upscale_video_url(self, video_url: str) -> str: + if not video_url or not self.upscale_on_finish: + return video_url + video_id = self._extract_video_id(video_url) + if not video_id: + logger.warning("Video upscale skipped: unable to extract video id") + return video_url + try: + async with AsyncSession() as session: + response = await VideoUpscaleReverse.request( + session, self.token, video_id + ) + payload = response.json() if response is not None else {} + hd_url = payload.get("hdMediaUrl") if isinstance(payload, dict) else None + if hd_url: + logger.info(f"Video upscale completed: {hd_url}") + return hd_url + except Exception as e: + logger.warning(f"Video upscale failed: {e}") + return video_url def _sse(self, content: str = "", role: str = None, finish: str = None) -> str: """Build SSE response.""" @@ -443,6 +499,9 @@ class VideoStreamProcessor(BaseProcessor): self.think_opened = False if video_url: + if self.upscale_on_finish: + yield self._sse("正在对视频进行超分辨率\n") + video_url = await self._upscale_video_url(video_url) dl_service = self._get_dl() rendered = await dl_service.render_video( video_url, self.token, thumbnail_url @@ -500,8 +559,42 @@ class VideoStreamProcessor(BaseProcessor): class VideoCollectProcessor(BaseProcessor): """Video non-stream response processor.""" - def __init__(self, model: str, token: str = ""): + def __init__(self, model: str, token: str = "", upscale_on_finish: bool = False): super().__init__(model, token) + self.upscale_on_finish = bool(upscale_on_finish) + + @staticmethod + def _extract_video_id(video_url: str) -> str: + if not video_url: + return "" + match = re.search(r"/generated/([0-9a-fA-F-]{32,36})/", video_url) + if match: + return match.group(1) + match = re.search(r"/([0-9a-fA-F-]{32,36})/generated_video", video_url) + if match: + return match.group(1) + return "" + + async def _upscale_video_url(self, video_url: str) -> str: + if not video_url or not self.upscale_on_finish: + return video_url + video_id = self._extract_video_id(video_url) + if not video_id: + logger.warning("Video upscale skipped: unable to extract video id") + return video_url + try: + async with AsyncSession() as session: + response = await VideoUpscaleReverse.request( + session, self.token, video_id + ) + payload = response.json() if response is not None else {} + hd_url = payload.get("hdMediaUrl") if isinstance(payload, dict) else None + if hd_url: + logger.info(f"Video upscale completed: {hd_url}") + return hd_url + except Exception as e: + logger.warning(f"Video upscale failed: {e}") + return video_url async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]: """Process and collect video response.""" @@ -528,6 +621,8 @@ class VideoCollectProcessor(BaseProcessor): thumbnail_url = video_resp.get("thumbnailImageUrl", "") if video_url: + if self.upscale_on_finish: + video_url = await self._upscale_video_url(video_url) dl_service = self._get_dl() content = await dl_service.render_video( video_url, self.token, thumbnail_url diff --git a/app/services/reverse/__init__.py b/app/services/reverse/__init__.py index 08734a8..6e8aebf 100644 --- a/app/services/reverse/__init__.py +++ b/app/services/reverse/__init__.py @@ -9,6 +9,7 @@ from .media_post import MediaPostReverse from .nsfw_mgmt import NsfwMgmtReverse from .rate_limits import RateLimitsReverse from .set_birth import SetBirthReverse +from .video_upscale import VideoUpscaleReverse from .ws_livekit import LivekitTokenReverse, LivekitWebSocketReverse from .ws_imagine import ImagineWebSocketReverse from .utils.headers import build_headers @@ -24,6 +25,7 @@ __all__ = [ "NsfwMgmtReverse", "RateLimitsReverse", "SetBirthReverse", + "VideoUpscaleReverse", "LivekitTokenReverse", "LivekitWebSocketReverse", "ImagineWebSocketReverse", diff --git a/app/services/reverse/media_post.py b/app/services/reverse/media_post.py index 044e918..6e70d53 100644 --- a/app/services/reverse/media_post.py +++ b/app/services/reverse/media_post.py @@ -25,6 +25,7 @@ class MediaPostReverse: token: str, mediaType: str, mediaUrl: str, + prompt: str = "", ) -> Any: """Create media post in Grok. @@ -51,10 +52,11 @@ class MediaPostReverse: ) # Build payload - payload = { - "mediaType": mediaType, - "mediaUrl": mediaUrl, - } + payload = {"mediaType": mediaType} + if mediaUrl: + payload["mediaUrl"] = mediaUrl + if prompt: + payload["prompt"] = prompt # Curl Config timeout = get_config("video.timeout") @@ -71,13 +73,18 @@ class MediaPostReverse: ) if response.status_code != 200: + content = "" + try: + content = await response.text() + except Exception: + pass logger.error( f"MediaPostReverse: Media post create failed, {response.status_code}", extra={"error_type": "UpstreamException"}, ) raise UpstreamException( message=f"MediaPostReverse: Media post create failed, {response.status_code}", - details={"status": response.status_code}, + details={"status": response.status_code, "body": content}, ) return response diff --git a/app/services/reverse/video_upscale.py b/app/services/reverse/video_upscale.py new file mode 100644 index 0000000..f6c70e1 --- /dev/null +++ b/app/services/reverse/video_upscale.py @@ -0,0 +1,109 @@ +""" +Reverse interface: video upscale. +""" + +import orjson +from typing import Any +from curl_cffi.requests import AsyncSession + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import UpstreamException +from app.services.token.service import TokenService +from app.services.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status + +VIDEO_UPSCALE_API = "https://grok.com/rest/media/video/upscale" + + +class VideoUpscaleReverse: + """/rest/media/video/upscale reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, video_id: str) -> Any: + """Upscale video (image upscaling endpoint) in Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + video_id: str, the video id. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + proxies = {"http": base_proxy, "https": base_proxy} if base_proxy else None + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com", + ) + + # Build payload + payload = {"videoId": video_id} + + # Curl Config + timeout = get_config("video.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + VIDEO_UPSCALE_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + content = "" + try: + content = await response.text() + except Exception: + pass + logger.error( + f"VideoUpscaleReverse: Upscale failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"VideoUpscaleReverse: Upscale failed, {response.status_code}", + details={"status": response.status_code, "body": content}, + ) + + return response + + return await retry_on_status(_do_request) + + except Exception as e: + # Handle upstream exception + if isinstance(e, UpstreamException): + status = None + if e.details and "status" in e.details: + status = e.details["status"] + else: + status = getattr(e, "status_code", None) + if status == 401: + try: + await TokenService.record_fail(token, status, "video_upscale_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"VideoUpscaleReverse: Upscale failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"VideoUpscaleReverse: Upscale failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["VideoUpscaleReverse"] diff --git a/app/services/token/manager.py b/app/services/token/manager.py index 4955909..1530cbe 100644 --- a/app/services/token/manager.py +++ b/app/services/token/manager.py @@ -299,6 +299,14 @@ class TokenManager: ) return None + def get_pool_name_for_token(self, token_str: str) -> Optional[str]: + """Return pool name for the given token string.""" + raw_token = token_str.replace("sso=", "") + for pool_name, pool in self.pools.items(): + if pool.get(raw_token): + return pool_name + return None + async def consume( self, token_str: str, effort: EffortType = EffortType.LOW ) -> bool: diff --git a/app/static/cache/cache.css b/app/static/admin/css/cache.css similarity index 100% rename from app/static/cache/cache.css rename to app/static/admin/css/cache.css diff --git a/app/static/config/config.css b/app/static/admin/css/config.css similarity index 80% rename from app/static/config/config.css rename to app/static/admin/css/config.css index b887da9..8fa851b 100644 --- a/app/static/config/config.css +++ b/app/static/admin/css/config.css @@ -28,6 +28,7 @@ .config-field { padding-top: 2px; + position: relative; } .config-field-title { @@ -45,3 +46,14 @@ .config-field-input { margin-top: 6px; } + +.config-field.has-action { + padding-right: 44px; +} + +.config-field-action { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); +} diff --git a/app/static/token/token.css b/app/static/admin/css/token.css similarity index 100% rename from app/static/token/token.css rename to app/static/admin/css/token.css diff --git a/app/static/cache/cache.js b/app/static/admin/js/cache.js similarity index 100% rename from app/static/cache/cache.js rename to app/static/admin/js/cache.js diff --git a/app/static/config/config.js b/app/static/admin/js/config.js similarity index 85% rename from app/static/config/config.js rename to app/static/admin/js/config.js index 5c5f0cd..e2978a6 100644 --- a/app/static/config/config.js +++ b/app/static/admin/js/config.js @@ -37,17 +37,17 @@ const LOCALE_MAP = { "label": "应用设置", "api_key": { title: "API 密钥", desc: "调用 Grok2API 服务的 Token(可选)。" }, "app_key": { title: "后台密码", desc: "登录 Grok2API 管理后台的密码(必填)。" }, - "public_key": { title: "Public Key", desc: "Public 接口访问密钥(可选)。" }, - "public_enabled": { title: "公开功能玩法", desc: "开启后 public 入口可访问(public_key 为空时默认放开)。" }, + "public_enabled": { title: "启用功能玩法", desc: "是否启用功能玩法入口(关闭则功能玩法页面不可访问)。" }, + "public_key": { title: "Public 密码", desc: "功能玩法页面的访问密码(可选)。" }, "app_url": { title: "应用地址", desc: "当前 Grok2API 服务的外部访问 URL,用于文件链接访问。" }, - "image_format": { title: "图片格式", desc: "生成的图片格式(url 或 base64)。" }, - "video_format": { title: "视频格式", desc: "生成的视频格式(html 或 url,url 为处理后的链接)。" }, - "temporary": { title: "临时对话", desc: "是否启用临时对话模式。" }, - "disable_memory": { title: "禁用记忆", desc: "禁用 Grok 记忆功能,以防止响应中出现不相关上下文。" }, + "image_format": { title: "图片格式", desc: "默认生成的图片格式(url 或 base64)。" }, + "video_format": { title: "视频格式", desc: "默认生成的视频格式(html 或 url,url 为处理后的链接)。" }, + "temporary": { title: "临时对话", desc: "是否默认启用临时对话模式。" }, + "disable_memory": { title: "禁用记忆", desc: "是否默认禁用 Grok 记忆功能。" }, "stream": { title: "流式响应", desc: "是否默认启用流式输出。" }, - "thinking": { title: "思维链", desc: "是否启用模型思维链输出。" }, - "dynamic_statsig": { title: "动态指纹", desc: "是否启用动态生成 Statsig 值。" }, - "filter_tags": { title: "过滤标签", desc: "自动过滤 Grok 响应中的特殊标签。" } + "thinking": { title: "思维链", desc: "是否默认启用思维链输出。" }, + "dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" }, + "filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" } }, @@ -258,6 +258,15 @@ function buildSecretInput(section, key, val) { const wrapper = document.createElement('div'); wrapper.className = 'flex items-center gap-2'; + const genBtn = document.createElement('button'); + genBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; + genBtn.type = 'button'; + genBtn.title = '生成'; + genBtn.innerHTML = ``; + genBtn.onclick = () => { + input.value = randomKey(16); + }; + const copyBtn = document.createElement('button'); copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; copyBtn.type = 'button'; @@ -265,11 +274,29 @@ function buildSecretInput(section, key, val) { copyBtn.onclick = () => copyToClipboard(input.value, copyBtn); wrapper.appendChild(input); + wrapper.appendChild(genBtn); wrapper.appendChild(copyBtn); return { input, node: wrapper }; } +function randomKey(len) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const out = []; + if (window.crypto && window.crypto.getRandomValues) { + const buf = new Uint8Array(len); + window.crypto.getRandomValues(buf); + for (let i = 0; i < len; i++) { + out.push(chars[buf[i] % chars.length]); + } + return out.join(''); + } + for (let i = 0; i < len; i++) { + out.push(chars[Math.floor(Math.random() * chars.length)]); + } + return out.join(''); +} + async function init() { apiKey = await ensureAdminKey(); if (apiKey === null) return; @@ -399,6 +426,23 @@ function buildFieldCard(section, key, val) { } fieldCard.appendChild(inputWrapper); + if (section === 'app' && key === 'public_enabled') { + fieldCard.classList.add('has-action'); + const link = document.createElement('a'); + link.href = '/login'; + link.className = 'config-field-action flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; + link.title = '功能玩法'; + link.setAttribute('aria-label', '功能玩法'); + link.innerHTML = ``; + link.style.display = val ? 'inline-flex' : 'none'; + fieldCard.appendChild(link); + if (built && built.input) { + built.input.addEventListener('change', () => { + link.style.display = built.input.checked ? 'inline-flex' : 'none'; + }); + } + } + return fieldCard; } diff --git a/app/static/login/login.js b/app/static/admin/js/login.js similarity index 100% rename from app/static/login/login.js rename to app/static/admin/js/login.js diff --git a/app/static/token/token.js b/app/static/admin/js/token.js similarity index 100% rename from app/static/token/token.js rename to app/static/admin/js/token.js diff --git a/app/static/cache/cache.html b/app/static/admin/pages/cache.html similarity index 93% rename from app/static/cache/cache.html rename to app/static/admin/pages/cache.html index 59051ad..5acb03e 100644 --- a/app/static/cache/cache.html +++ b/app/static/admin/pages/cache.html @@ -5,13 +5,13 @@ Grok2API - 缓存管理 - + - - - + + + @@ -196,13 +196,13 @@ - - - - - - - + + + + + + + diff --git a/app/static/config/config.html b/app/static/admin/pages/config.html similarity index 75% rename from app/static/config/config.html rename to app/static/admin/pages/config.html index 95a244e..2225fff 100644 --- a/app/static/config/config.html +++ b/app/static/admin/pages/config.html @@ -5,13 +5,13 @@ Grok2API - 配置管理 - + - - - + + + @@ -46,11 +46,11 @@ - - - - - + + + + + diff --git a/app/static/login/login.html b/app/static/admin/pages/login.html similarity index 82% rename from app/static/login/login.html rename to app/static/admin/pages/login.html index 3ee9a91..9e94cd5 100644 --- a/app/static/login/login.html +++ b/app/static/admin/pages/login.html @@ -5,7 +5,7 @@ Grok2API - 登录 - + @@ -24,9 +24,9 @@ } } - - - + + + @@ -55,10 +55,10 @@ - - - - + + + + diff --git a/app/static/token/token.html b/app/static/admin/pages/token.html similarity index 95% rename from app/static/token/token.html rename to app/static/admin/pages/token.html index ce6ca07..6302d95 100644 --- a/app/static/token/token.html +++ b/app/static/admin/pages/token.html @@ -5,13 +5,13 @@ Grok2API - Token 管理 - + - - - + + + @@ -291,13 +291,13 @@ - - - - - - - + + + + + + + diff --git a/app/static/common/common.css b/app/static/common/css/common.css similarity index 100% rename from app/static/common/common.css rename to app/static/common/css/common.css diff --git a/app/static/login/login.css b/app/static/common/css/login.css similarity index 100% rename from app/static/login/login.css rename to app/static/common/css/login.css diff --git a/app/static/common/toast.css b/app/static/common/css/toast.css similarity index 100% rename from app/static/common/toast.css rename to app/static/common/css/toast.css diff --git a/app/static/common/footer.html b/app/static/common/html/footer.html similarity index 100% rename from app/static/common/footer.html rename to app/static/common/html/footer.html diff --git a/app/static/common/header.html b/app/static/common/html/header.html similarity index 73% rename from app/static/common/header.html rename to app/static/common/html/header.html index f994e47..2330a99 100644 --- a/app/static/common/header.html +++ b/app/static/common/html/header.html @@ -14,17 +14,9 @@ class="text-xs text-[var(--accents-4)] hover:text-black">@chenyme
- -
- Public + Token管理 + 配置管理 + 缓存管理
Imagine + Video Voice Live - Public Key diff --git a/app/static/favicon/favicon.ico b/app/static/common/img/favicon/favicon.ico similarity index 100% rename from app/static/favicon/favicon.ico rename to app/static/common/img/favicon/favicon.ico diff --git a/app/static/common/admin-auth.js b/app/static/common/js/admin-auth.js similarity index 100% rename from app/static/common/admin-auth.js rename to app/static/common/js/admin-auth.js diff --git a/app/static/common/batch-sse.js b/app/static/common/js/batch-sse.js similarity index 100% rename from app/static/common/batch-sse.js rename to app/static/common/js/batch-sse.js diff --git a/app/static/common/draggable.js b/app/static/common/js/draggable.js similarity index 100% rename from app/static/common/draggable.js rename to app/static/common/js/draggable.js diff --git a/app/static/common/footer.js b/app/static/common/js/footer.js similarity index 85% rename from app/static/common/footer.js rename to app/static/common/js/footer.js index 6256b2d..e32efdc 100644 --- a/app/static/common/footer.js +++ b/app/static/common/js/footer.js @@ -2,7 +2,7 @@ async function loadAdminFooter() { const container = document.getElementById('app-footer'); if (!container) return; try { - const res = await fetch('/static/common/footer.html?v=1'); + const res = await fetch('/static/common/html/footer.html?v=1'); if (!res.ok) return; container.innerHTML = await res.text(); } catch (e) { diff --git a/app/static/common/header.js b/app/static/common/js/header.js similarity index 93% rename from app/static/common/header.js rename to app/static/common/js/header.js index 0433458..a7e0a25 100644 --- a/app/static/common/header.js +++ b/app/static/common/js/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=5'); + const res = await fetch('/static/common/html/header.html?v=1'); if (!res.ok) return; container.innerHTML = await res.text(); const path = window.location.pathname; diff --git a/app/static/common/public-header.js b/app/static/common/js/public-header.js similarity index 59% rename from app/static/common/public-header.js rename to app/static/common/js/public-header.js index 7cd7915..82797ec 100644 --- a/app/static/common/public-header.js +++ b/app/static/common/js/public-header.js @@ -2,9 +2,21 @@ async function loadPublicHeader() { const container = document.getElementById('app-header'); if (!container) return; try { - const res = await fetch('/static/common/public-header.html?v=1'); + const res = await fetch('/static/common/html/public-header.html?v=1'); if (!res.ok) return; container.innerHTML = await res.text(); + const logoutBtn = container.querySelector('#public-logout-btn'); + if (logoutBtn) { + logoutBtn.classList.add('hidden'); + try { + const verify = await fetch('/v1/public/verify', { method: 'GET' }); + if (verify.status === 401) { + logoutBtn.classList.remove('hidden'); + } + } catch (e) { + // Ignore verification errors and keep it hidden + } + } const path = window.location.pathname; const links = container.querySelectorAll('a[data-nav]'); links.forEach((link) => { diff --git a/app/static/common/toast.js b/app/static/common/js/toast.js similarity index 100% rename from app/static/common/toast.js rename to app/static/common/js/toast.js diff --git a/app/static/imagine/imagine.css b/app/static/public/css/imagine.css similarity index 80% rename from app/static/imagine/imagine.css rename to app/static/public/css/imagine.css index 99c462b..5a15513 100644 --- a/app/static/imagine/imagine.css +++ b/app/static/public/css/imagine.css @@ -23,6 +23,10 @@ overflow: visible; } +.imagine-card.settings-card { + padding: 16px; +} + .imagine-card-collapsible { transition: all 0.3s ease; } @@ -138,65 +142,143 @@ } .imagine-textarea { - min-height: 96px; + height: auto; + min-height: 0; resize: vertical; line-height: 1.5; + flex: 1; } -.settings-layout { +.settings-grid { + --row-gap: 10px; + --row-h: 52px; + display: grid; + grid-template-columns: 2.2fr 1fr; + grid-template-rows: calc(var(--row-h) * 2 + var(--row-gap)) var(--row-h) var(--row-h); + column-gap: 16px; + row-gap: var(--row-gap); + align-items: stretch; +} + +.settings-block { display: flex; flex-direction: column; - gap: 16px; + gap: 6px; + height: 100%; } -.settings-top { +.settings-block-prompt { + grid-column: 1; + grid-row: 1; +} + +.settings-block-duo { + grid-column: 2; + grid-row: 1; + height: 100%; display: grid; - grid-template-columns: 2fr 1fr; - gap: 16px; - align-items: start; - padding-bottom: 8px; + grid-template-rows: 1fr 1fr; + gap: 10px; +} + +.settings-block-row2 { + grid-column: 1; + grid-row: 2; +} + +.settings-block-row2b { + grid-column: 2; + grid-row: 2; +} + +.settings-block-row3 { + grid-column: 1; + grid-row: 3; +} + +.settings-block-row3b { + grid-column: 2; + grid-row: 3; +} + +.settings-block-single { + height: 100%; + align-self: stretch; + justify-content: stretch; } .settings-prompt { display: flex; flex-direction: column; + height: 100%; } -.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 { +.settings-row { display: grid; grid-template-columns: 1fr 1fr; - gap: 16px; + gap: 8px; + align-items: stretch; + height: 100%; } -.settings-toggle { +.settings-field { display: flex; flex-direction: column; + height: 100%; + justify-content: space-between; } -.settings-toggle .field-label { - margin-bottom: 6px; +.settings-field .field-label { + margin-bottom: 0; +} + +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + height: 100%; + padding: 0 10px; + border-radius: 10px; + background: #f7f7f8; + border: 1px solid var(--accents-1); +} + +.toggle-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.toggle-title { + font-size: 11px; + font-weight: 600; + color: var(--accents-6); + line-height: 1.2; +} + +.toggle-desc { + font-size: 10px; + color: var(--accents-4); + line-height: 1.2; +} + +.toggle-row-fill { + height: 100%; } .settings-folder { display: flex; flex-direction: column; + align-items: flex-start; + height: 100%; + justify-content: space-between; } .settings-folder .field-label { - margin-bottom: 6px; + margin-bottom: 0; + line-height: 1; } .folder-select-btn { @@ -217,16 +299,29 @@ } @media (max-width: 768px) { - .settings-top { + .settings-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + + .settings-row { grid-template-columns: 1fr; } - - .settings-bottom-row { - grid-template-columns: 1fr; + + .settings-block-prompt, + .settings-block-duo, + .settings-block-row2, + .settings-block-row2b, + .settings-block-row3, + .settings-block-row3b { + grid-column: 1; + grid-row: auto; } - - .settings-toggles { - grid-template-columns: 1fr; + + .settings-block-duo { + height: auto; + display: flex; + flex-direction: column; } } @@ -374,14 +469,14 @@ } .meta-item { - padding: 9px 12px; + padding: 12px; border-radius: 10px; background: #f7f7f8; display: flex; align-items: center; justify-content: space-between; gap: 12px; - min-height: 38px; + min-height: 42px; } .meta-label { @@ -449,9 +544,39 @@ .imagine-empty { text-align: center; - padding: 28px 0 8px; + padding: 32px 24px; font-size: 12px; color: var(--accents-4); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 220px; + border: 1px dashed var(--border); + border-radius: 14px; + background: linear-gradient(135deg, #fafafa 0%, #f3f4f6 100%); +} + +.empty-title { + font-size: 13px; + font-weight: 600; + color: var(--accents-6); +} + +.empty-subtitle { + font-size: 11px; + color: var(--accents-4); +} + +.empty-hint { + margin-top: 4px; + font-size: 10px; + color: var(--accents-3); + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--accents-1); + background: #fff; } .waterfall { diff --git a/app/static/public/css/video.css b/app/static/public/css/video.css new file mode 100644 index 0000000..a634b7a --- /dev/null +++ b/app/static/public/css/video.css @@ -0,0 +1,482 @@ +:root { + --video-surface: #ffffff; + --video-muted: #f2f4f7; + --video-bg: #f6f7fb; + --video-outline: #e6e6e6; + --video-ink: #0f172a; + --video-accent: #0f172a; + --video-glow: rgba(15, 23, 42, 0.08); +} + +body { + background-color: var(--video-bg); + background-image: none; +} + +.video-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.video-hero { + padding: 16px 20px; + border-radius: 12px; + border: none; + background: #fff; + box-shadow: none; +} + +.hero-kicker { + font-size: 11px; + color: var(--accents-4); + text-transform: uppercase; + letter-spacing: 0.18em; + margin-bottom: 6px; +} + +.hero-note { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 12px; + font-size: 11px; + color: var(--accents-5); +} + +.hero-note-item { + display: flex; + align-items: center; + gap: 8px; + background: #f5f5f5; + border: none; + padding: 6px 10px; + border-radius: 999px; +} + +.hero-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--video-accent); + display: inline-block; +} + +.video-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.video-top-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 24px; + align-items: stretch; + margin-bottom: 24px; +} + +@media (max-width: 1024px) { + .video-top-grid { + grid-template-columns: 1fr; + } +} + +.video-card { + background: var(--video-surface); + border: none; + border-radius: 12px; + padding: 20px; + box-shadow: none; + position: relative; + overflow: hidden; + --field-height: 32px; + --field-label: 11px; + --field-label-gap: 6px; + --field-block: calc(var(--field-height) + var(--field-label) + var(--field-label-gap)); + --field-row-gap: 12px; +} + +.video-card::after { + content: none; +} + +.video-card-glow::before { + content: none; +} + +.video-card-contrast { + background: #fff; + color: var(--video-ink); + border: none; +} + +.video-card-contrast .card-title, +.video-card-contrast .meta-label, +.video-card-contrast .meta-value, +.video-card-contrast .progress-meta, +.video-card-contrast .video-meta { + color: #e2e8f0; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--video-ink); + margin-bottom: 12px; +} + +.field-label { + display: block; + font-size: 11px; + color: var(--accents-4); + margin-bottom: 6px; + line-height: 1; +} + +.video-card input.geist-input, +.video-card select.geist-input { + height: var(--field-height); +} + +.settings-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: var(--field-block) var(--field-block) auto; + column-gap: 16px; + row-gap: var(--field-row-gap); + align-items: start; +} + +.settings-block { + display: flex; + flex-direction: column; +} + +.prompt-block { + grid-column: 1; + grid-row: 1 / span 2; +} + +.ref-block { + grid-column: 1; + grid-row: 3; +} + +.ratio-block { + grid-column: 2; + grid-row: 1; +} + +.length-block { + grid-column: 3; + grid-row: 1; +} + +.resolution-block { + grid-column: 2; + grid-row: 2; +} + +.preset-block { + grid-column: 3; + grid-row: 2; +} + +.upload-block { + grid-column: 2; + grid-row: 3; +} + +.clear-block { + grid-column: 3; + grid-row: 3; +} + +.upload-block .geist-button-outline, +.clear-block .geist-button-outline { + height: 32px; + width: 100%; +} + +.ref-controls { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.ref-controls .geist-input { + min-width: 0; +} + +.ref-file-input { + display: none; +} + +.ref-meta { + margin-top: 6px; +} + +.ref-name { + font-size: 11px; + color: var(--accents-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + display: inline-block; +} + +@media (max-width: 640px) { + .ref-controls { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .settings-grid { + grid-template-columns: 1fr; + grid-template-rows: none; + } + + .settings-block { + grid-column: auto; + grid-row: auto; + } +} + +.video-textarea { + min-height: calc(var(--field-block) * 2 + var(--field-row-gap) - var(--field-label) - var(--field-label-gap)); + height: calc(var(--field-block) * 2 + var(--field-row-gap) - var(--field-label) - var(--field-label-gap)); + resize: vertical; +} + +.status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.status-text { + font-size: 11px; + color: var(--accents-4); +} + +.status-text.connected { + color: #059669; +} + +.status-text.connecting { + color: #d97706; +} + +.status-text.error { + color: #dc2626; +} + +.progress-wrap { + margin-bottom: 12px; +} + +.progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: #f0f0f0; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + width: 0%; + border-radius: 999px; + background: #111; + transition: width 0.3s ease; + position: absolute; + left: 0; + top: 0; +} + +.progress-bar.indeterminate .progress-fill { + width: 40%; + animation: progress-indeterminate 1.2s ease-in-out infinite; +} + +@keyframes progress-indeterminate { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(220%); + } +} + +.progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 11px; + color: var(--accents-4); +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.meta-item { + padding: 10px 12px; + border-radius: 10px; + background: #f5f5f5; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.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-wrap: anywhere; +} + +.video-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.preview-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.video-empty { + text-align: center; + color: var(--accents-4); + font-size: 12px; + padding: 42px 12px; + background: #f5f5f5; + border-radius: 12px; + border: 1px dashed var(--video-outline); +} + +.video-stage { + min-height: 240px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--video-outline); + background: #111; + display: flex; + flex-direction: column; + gap: 12px; + align-items: stretch; +} + +.video-item { + background: #000; + border-radius: 10px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.video-item-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #e2e8f0; + font-size: 11px; +} + +.video-item-title { + font-size: 11px; + color: #e2e8f0; +} + +.video-item-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.video-item-actions .geist-button-outline { + height: 26px; + padding: 0 10px; + border-color: rgba(148, 163, 184, 0.35); + color: #e2e8f0; +} + +.video-item-body { + border-radius: 8px; + background: #000; + overflow: hidden; + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; +} + +.video-item video { + width: 100%; + border-radius: 8px; + background: #000; + max-height: 380px; +} + +.video-item-link { + display: none; + font-size: 11px; + color: rgba(226, 232, 240, 0.7); + word-break: break-all; +} + +.video-item-link.has-url { + display: block; +} + +.video-item-placeholder { + font-size: 12px; + color: rgba(226, 232, 240, 0.7); +} + +.video-item.is-pending .video-item-actions .video-open { + display: none; +} + +.video-stage.hidden, +.video-empty.hidden { + display: none; +} + +.preview-actions .geist-button-outline { + height: 30px; +} + +.video-card-contrast .geist-button-outline { + border-color: var(--video-outline); + color: var(--accents-7); +} diff --git a/app/static/voice/voice.css b/app/static/public/css/voice.css similarity index 100% rename from app/static/voice/voice.css rename to app/static/public/css/voice.css diff --git a/app/static/imagine/imagine.js b/app/static/public/js/imagine.js similarity index 79% rename from app/static/imagine/imagine.js rename to app/static/public/js/imagine.js index 306c75b..cb9234c 100644 --- a/app/static/imagine/imagine.js +++ b/app/static/public/js/imagine.js @@ -7,6 +7,9 @@ const concurrentSelect = document.getElementById('concurrentSelect'); const autoScrollToggle = document.getElementById('autoScrollToggle'); const autoDownloadToggle = document.getElementById('autoDownloadToggle'); + const reverseInsertToggle = document.getElementById('reverseInsertToggle'); + const autoFilterToggle = document.getElementById('autoFilterToggle'); + const nsfwSelect = document.getElementById('nsfwSelect'); const selectFolderBtn = document.getElementById('selectFolderBtn'); const folderPath = document.getElementById('folderPath'); const statusText = document.getElementById('statusText'); @@ -36,6 +39,9 @@ let useFileSystemAPI = false; let isSelectionMode = false; let selectedImages = new Set(); + let streamSequence = 0; + const streamImageMap = new Map(); + let finalMinBytesDefault = 100000; function toast(message, type) { if (typeof showToast === 'function') { @@ -103,6 +109,23 @@ function updateModeValue() {} + async function loadFilterDefaults() { + try { + const res = await fetch('/v1/public/imagine/config', { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + const value = parseInt(data && data.final_min_bytes, 10); + if (Number.isFinite(value) && value >= 0) { + finalMinBytesDefault = value; + } + if (nsfwSelect && typeof data.nsfw === 'boolean') { + nsfwSelect.value = data.nsfw ? 'true' : 'false'; + } + } catch (e) { + // ignore + } + } + function updateLatency(value) { if (value) { @@ -121,6 +144,15 @@ function updateError(value) {} + function isLikelyBase64(raw) { + if (!raw) return false; + if (raw.startsWith('data:')) return true; + if (raw.startsWith('http://') || raw.startsWith('https://')) return false; + const head = raw.slice(0, 16); + if (head.startsWith('/9j/') || head.startsWith('iVBOR') || head.startsWith('R0lGOD')) return true; + return /^[A-Za-z0-9+/=\s]+$/.test(raw); + } + function inferMime(base64) { if (!base64) return 'image/jpeg'; if (base64.startsWith('iVBOR')) return 'image/png'; @@ -129,6 +161,31 @@ return 'image/jpeg'; } + function estimateBase64Bytes(raw) { + if (!raw) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return null; + } + if (raw.startsWith('/') && !isLikelyBase64(raw)) { + return null; + } + let base64 = raw; + if (raw.startsWith('data:')) { + const comma = raw.indexOf(','); + base64 = comma >= 0 ? raw.slice(comma + 1) : ''; + } + base64 = base64.replace(/\s/g, ''); + if (!base64) return 0; + let padding = 0; + if (base64.endsWith('==')) padding = 2; + else if (base64.endsWith('=')) padding = 1; + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); + } + + function getFinalMinBytes() { + return Number.isFinite(finalMinBytesDefault) && finalMinBytesDefault >= 0 ? finalMinBytesDefault : 100000; + } + function dataUrlToBlob(dataUrl) { const parts = (dataUrl || '').split(','); if (parts.length < 2) return null; @@ -149,14 +206,14 @@ } } - async function createImagineTask(prompt, ratio, authHeader) { + async function createImagineTask(prompt, ratio, authHeader, nsfwEnabled) { const res = await fetch('/v1/public/imagine/start', { method: 'POST', headers: { ...buildAuthHeaders(authHeader), 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, aspect_ratio: ratio }) + body: JSON.stringify({ prompt, aspect_ratio: ratio, nsfw: nsfwEnabled }) }); if (!res.ok) { const text = await res.text(); @@ -166,10 +223,10 @@ return data && data.task_id ? String(data.task_id) : ''; } - async function createImagineTasks(prompt, ratio, concurrent, authHeader) { + async function createImagineTasks(prompt, ratio, concurrent, authHeader, nsfwEnabled) { const tasks = []; for (let i = 0; i < concurrent; i++) { - const taskId = await createImagineTask(prompt, ratio, authHeader); + const taskId = await createImagineTask(prompt, ratio, authHeader, nsfwEnabled); if (!taskId) { throw new Error('Missing task id'); } @@ -239,6 +296,13 @@ function appendImage(base64, meta) { if (!waterfall) return; + if (autoFilterToggle && autoFilterToggle.checked) { + const bytes = estimateBase64Bytes(base64 || ''); + const minBytes = getFinalMinBytes(); + if (bytes !== null && bytes < minBytes) { + return; + } + } if (emptyState) { emptyState.style.display = 'none'; } @@ -282,10 +346,18 @@ item.classList.add('selection-mode'); } - waterfall.appendChild(item); + if (reverseInsertToggle && reverseInsertToggle.checked) { + waterfall.prepend(item); + } else { + waterfall.appendChild(item); + } if (autoScrollToggle && autoScrollToggle.checked) { - window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + if (reverseInsertToggle && reverseInsertToggle.checked) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + } } if (autoDownloadToggle && autoDownloadToggle.checked) { @@ -304,6 +376,132 @@ } } + function upsertStreamImage(raw, meta, imageId, isFinal) { + if (!waterfall || !raw) return; + if (emptyState) { + emptyState.style.display = 'none'; + } + + if (isFinal && autoFilterToggle && autoFilterToggle.checked) { + const bytes = estimateBase64Bytes(raw); + const minBytes = getFinalMinBytes(); + if (bytes !== null && bytes < minBytes) { + const existing = imageId ? streamImageMap.get(imageId) : null; + if (existing) { + if (selectedImages.has(existing)) { + selectedImages.delete(existing); + updateSelectedCount(); + } + existing.remove(); + streamImageMap.delete(imageId); + if (imageCount > 0) { + imageCount -= 1; + updateCount(imageCount); + } + } + return; + } + } + + const isDataUrl = typeof raw === 'string' && raw.startsWith('data:'); + const looksLikeBase64 = typeof raw === 'string' && isLikelyBase64(raw); + const isHttpUrl = typeof raw === 'string' && (raw.startsWith('http://') || raw.startsWith('https://') || (raw.startsWith('/') && !looksLikeBase64)); + const mime = isDataUrl || isHttpUrl ? '' : inferMime(raw); + const dataUrl = isDataUrl || isHttpUrl ? raw : `data:${mime};base64,${raw}`; + + let item = imageId ? streamImageMap.get(imageId) : null; + let isNew = false; + if (!item) { + isNew = true; + streamSequence += 1; + const sequence = streamSequence; + + item = document.createElement('div'); + item.className = 'waterfall-item'; + + const checkbox = document.createElement('div'); + checkbox.className = 'image-checkbox'; + + const img = document.createElement('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = imageId ? `image-${imageId}` : 'image'; + img.src = dataUrl; + + const metaBar = document.createElement('div'); + metaBar.className = 'waterfall-meta'; + const left = document.createElement('div'); + left.textContent = `#${sequence}`; + const right = document.createElement('span'); + right.textContent = ''; + if (meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } + + metaBar.appendChild(left); + metaBar.appendChild(right); + + item.appendChild(checkbox); + item.appendChild(img); + item.appendChild(metaBar); + + const prompt = (meta && meta.prompt) ? String(meta.prompt) : (promptInput ? promptInput.value.trim() : ''); + item.dataset.imageUrl = dataUrl; + item.dataset.prompt = prompt || 'image'; + + if (isSelectionMode) { + item.classList.add('selection-mode'); + } + + if (reverseInsertToggle && reverseInsertToggle.checked) { + waterfall.prepend(item); + } else { + waterfall.appendChild(item); + } + + if (imageId) { + streamImageMap.set(imageId, item); + } + + imageCount += 1; + updateCount(imageCount); + } else { + const img = item.querySelector('img'); + if (img) { + img.src = dataUrl; + } + item.dataset.imageUrl = dataUrl; + const right = item.querySelector('.waterfall-meta span'); + if (right && meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } + } + + updateError(''); + + if (isNew && autoScrollToggle && autoScrollToggle.checked) { + if (reverseInsertToggle && reverseInsertToggle.checked) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + } + } + + if (isFinal && autoDownloadToggle && autoDownloadToggle.checked) { + const timestamp = Date.now(); + const ext = mime === 'image/png' ? 'png' : 'jpg'; + const filename = `imagine_${timestamp}_${imageId || streamSequence}.${ext}`; + + if (useFileSystemAPI && directoryHandle) { + saveToFileSystem(raw, filename).catch(() => { + downloadImage(raw, filename); + }); + } else { + downloadImage(raw, filename); + } + } + } + function handleMessage(raw) { let data = null; try { @@ -313,7 +511,15 @@ } if (!data || typeof data !== 'object') return; - if (data.type === 'image') { + if (data.type === 'image_generation.partial_image' || data.type === 'image_generation.completed') { + const imageId = data.image_id || data.imageId; + const payload = data.b64_json || data.url || data.image; + if (!payload || !imageId) { + return; + } + const isFinal = data.type === 'image_generation.completed' || data.stage === 'final'; + upsertStreamImage(payload, data, imageId, isFinal); + } else if (data.type === 'image') { imageCount += 1; updateCount(imageCount); updateLatency(data.elapsed_ms); @@ -329,8 +535,8 @@ } setStatus('', '已停止'); } - } else if (data.type === 'error') { - const message = data.message || '生成失败'; + } else if (data.type === 'error' || data.error) { + const message = data.message || (data.error && data.error.message) || '生成失败'; updateError(message); toast(message, 'error'); } @@ -442,6 +648,7 @@ const concurrent = concurrentSelect ? parseInt(concurrentSelect.value, 10) : 1; const ratio = ratioSelect ? ratioSelect.value : '2:3'; + const nsfwEnabled = nsfwSelect ? nsfwSelect.value === 'true' : true; if (isRunning) { toast('已在运行中', 'warning'); @@ -459,7 +666,7 @@ let taskIds = []; try { - taskIds = await createImagineTasks(prompt, ratio, concurrent, authHeader); + taskIds = await createImagineTasks(prompt, ratio, concurrent, authHeader, nsfwEnabled); } catch (e) { setStatus('error', '创建任务失败'); startBtn.disabled = false; @@ -557,10 +764,12 @@ if (!ws || ws.readyState !== WebSocket.OPEN) return; const prompt = promptOverride || (promptInput ? promptInput.value.trim() : ''); const ratio = ratioSelect ? ratioSelect.value : '2:3'; + const nsfwEnabled = nsfwSelect ? nsfwSelect.value === 'true' : true; const payload = { type: 'start', prompt, - aspect_ratio: ratio + aspect_ratio: ratio, + nsfw: nsfwEnabled }; ws.send(JSON.stringify(payload)); updateError(''); @@ -590,6 +799,8 @@ if (waterfall) { waterfall.innerHTML = ''; } + streamImageMap.clear(); + streamSequence = 0; imageCount = 0; totalLatency = 0; latencyCount = 0; @@ -624,6 +835,8 @@ }); } + loadFilterDefaults(); + if (ratioSelect) { ratioSelect.addEventListener('change', () => { if (isRunning) { diff --git a/app/static/public/login.js b/app/static/public/js/login.js similarity index 100% rename from app/static/public/login.js rename to app/static/public/js/login.js diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js new file mode 100644 index 0000000..076483c --- /dev/null +++ b/app/static/public/js/video.js @@ -0,0 +1,640 @@ +(() => { + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const clearBtn = document.getElementById('clearBtn'); + const promptInput = document.getElementById('promptInput'); + const imageUrlInput = document.getElementById('imageUrlInput'); + const imageFileInput = document.getElementById('imageFileInput'); + const imageFileName = document.getElementById('imageFileName'); + const clearImageFileBtn = document.getElementById('clearImageFileBtn'); + const selectImageFileBtn = document.getElementById('selectImageFileBtn'); + const ratioSelect = document.getElementById('ratioSelect'); + const lengthSelect = document.getElementById('lengthSelect'); + const resolutionSelect = document.getElementById('resolutionSelect'); + const presetSelect = document.getElementById('presetSelect'); + const statusText = document.getElementById('statusText'); + const progressBar = document.getElementById('progressBar'); + const progressFill = document.getElementById('progressFill'); + const progressText = document.getElementById('progressText'); + const durationValue = document.getElementById('durationValue'); + const aspectValue = document.getElementById('aspectValue'); + const lengthValue = document.getElementById('lengthValue'); + const resolutionValue = document.getElementById('resolutionValue'); + const presetValue = document.getElementById('presetValue'); + const videoEmpty = document.getElementById('videoEmpty'); + const videoStage = document.getElementById('videoStage'); + + let currentSource = null; + let currentTaskId = ''; + let isRunning = false; + let progressBuffer = ''; + let contentBuffer = ''; + let collectingContent = false; + let startAt = 0; + let fileDataUrl = ''; + let elapsedTimer = null; + let lastProgress = 0; + let currentPreviewItem = null; + let previewCount = 0; + const DEFAULT_REASONING_EFFORT = 'low'; + + 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(running) { + if (!startBtn || !stopBtn) return; + if (running) { + startBtn.classList.add('hidden'); + stopBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + stopBtn.classList.add('hidden'); + startBtn.disabled = false; + } + } + + function updateProgress(value) { + const safe = Math.max(0, Math.min(100, Number(value) || 0)); + lastProgress = safe; + if (progressFill) { + progressFill.style.width = `${safe}%`; + } + if (progressText) { + progressText.textContent = `${safe}%`; + } + } + + function updateMeta() { + if (aspectValue && ratioSelect) { + aspectValue.textContent = ratioSelect.value; + } + if (lengthValue && lengthSelect) { + lengthValue.textContent = `${lengthSelect.value}s`; + } + if (resolutionValue && resolutionSelect) { + resolutionValue.textContent = resolutionSelect.value; + } + if (presetValue && presetSelect) { + presetValue.textContent = presetSelect.value; + } + } + + function resetOutput(keepPreview) { + progressBuffer = ''; + contentBuffer = ''; + collectingContent = false; + lastProgress = 0; + currentPreviewItem = null; + updateProgress(0); + setIndeterminate(false); + if (!keepPreview) { + if (videoStage) { + videoStage.innerHTML = ''; + videoStage.classList.add('hidden'); + } + if (videoEmpty) { + videoEmpty.classList.remove('hidden'); + } + previewCount = 0; + } + if (durationValue) { + durationValue.textContent = '耗时 -'; + } + } + + function initPreviewSlot() { + if (!videoStage) return; + previewCount += 1; + currentPreviewItem = document.createElement('div'); + currentPreviewItem.className = 'video-item'; + currentPreviewItem.dataset.index = String(previewCount); + currentPreviewItem.classList.add('is-pending'); + + const header = document.createElement('div'); + header.className = 'video-item-bar'; + + const title = document.createElement('div'); + title.className = 'video-item-title'; + title.textContent = `视频 ${previewCount}`; + + const actions = document.createElement('div'); + actions.className = 'video-item-actions'; + + const openBtn = document.createElement('a'); + openBtn.className = 'geist-button-outline text-xs px-3 video-open hidden'; + openBtn.target = '_blank'; + openBtn.rel = 'noopener'; + openBtn.textContent = '打开'; + + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'geist-button-outline text-xs px-3 video-download'; + downloadBtn.type = 'button'; + downloadBtn.textContent = '下载'; + downloadBtn.disabled = true; + + actions.appendChild(openBtn); + actions.appendChild(downloadBtn); + header.appendChild(title); + header.appendChild(actions); + + const body = document.createElement('div'); + body.className = 'video-item-body'; + body.innerHTML = '
生成中…
'; + + const link = document.createElement('div'); + link.className = 'video-item-link'; + + currentPreviewItem.appendChild(header); + currentPreviewItem.appendChild(body); + currentPreviewItem.appendChild(link); + videoStage.appendChild(currentPreviewItem); + videoStage.classList.remove('hidden'); + if (videoEmpty) { + videoEmpty.classList.add('hidden'); + } + } + + function ensurePreviewSlot() { + if (!currentPreviewItem) { + initPreviewSlot(); + } + return currentPreviewItem; + } + + function updateItemLinks(item, url) { + if (!item) return; + const openBtn = item.querySelector('.video-open'); + const downloadBtn = item.querySelector('.video-download'); + const link = item.querySelector('.video-item-link'); + const safeUrl = url || ''; + item.dataset.url = safeUrl; + if (link) { + link.textContent = safeUrl; + link.classList.toggle('has-url', Boolean(safeUrl)); + } + if (openBtn) { + if (safeUrl) { + openBtn.href = safeUrl; + openBtn.classList.remove('hidden'); + } else { + openBtn.classList.add('hidden'); + openBtn.removeAttribute('href'); + } + } + if (downloadBtn) { + downloadBtn.dataset.url = safeUrl; + downloadBtn.disabled = !safeUrl; + } + if (safeUrl) { + item.classList.remove('is-pending'); + } + } + + function setIndeterminate(active) { + if (!progressBar) return; + if (active) { + progressBar.classList.add('indeterminate'); + } else { + progressBar.classList.remove('indeterminate'); + } + } + + function startElapsedTimer() { + stopElapsedTimer(); + if (!durationValue) return; + elapsedTimer = setInterval(() => { + if (!startAt) return; + const seconds = Math.max(0, Math.round((Date.now() - startAt) / 1000)); + durationValue.textContent = `耗时 ${seconds}s`; + }, 1000); + } + + function stopElapsedTimer() { + if (elapsedTimer) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + } + + function clearFileSelection() { + fileDataUrl = ''; + if (imageFileInput) { + imageFileInput.value = ''; + } + if (imageFileName) { + imageFileName.textContent = '未选择文件'; + } + } + + function normalizeAuthHeader(authHeader) { + if (!authHeader) return ''; + if (authHeader.startsWith('Bearer ')) { + return authHeader.slice(7).trim(); + } + return authHeader; + } + + function buildSseUrl(taskId, rawPublicKey) { + const httpProtocol = window.location.protocol === 'https:' ? 'https' : 'http'; + const base = `${httpProtocol}://${window.location.host}/v1/public/video/sse`; + const params = new URLSearchParams(); + params.set('task_id', taskId); + params.set('t', String(Date.now())); + if (rawPublicKey) { + params.set('public_key', rawPublicKey); + } + return `${base}?${params.toString()}`; + } + + async function createVideoTask(authHeader) { + const prompt = promptInput ? promptInput.value.trim() : ''; + const rawUrl = imageUrlInput ? imageUrlInput.value.trim() : ''; + if (fileDataUrl && rawUrl) { + toast('参考图只能选择其一:URL/Base64 或 本地上传', 'error'); + throw new Error('invalid_reference'); + } + const imageUrl = fileDataUrl || rawUrl; + const res = await fetch('/v1/public/video/start', { + method: 'POST', + headers: { + ...buildAuthHeaders(authHeader), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + prompt, + image_url: imageUrl || null, + reasoning_effort: DEFAULT_REASONING_EFFORT, + aspect_ratio: ratioSelect ? ratioSelect.value : '3:2', + video_length: lengthSelect ? parseInt(lengthSelect.value, 10) : 6, + resolution_name: resolutionSelect ? resolutionSelect.value : '480p', + preset: presetSelect ? presetSelect.value : 'normal' + }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to create task'); + } + const data = await res.json(); + return data && data.task_id ? String(data.task_id) : ''; + } + + async function stopVideoTask(taskId, authHeader) { + if (!taskId) return; + try { + await fetch('/v1/public/video/stop', { + method: 'POST', + headers: { + ...buildAuthHeaders(authHeader), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ task_ids: [taskId] }) + }); + } catch (e) { + // ignore + } + } + + function extractVideoInfo(buffer) { + if (!buffer) return null; + if (buffer.includes('/gi); + if (matches && matches.length) { + return { html: matches[matches.length - 1] }; + } + } + const mdMatches = buffer.match(/\[video\]\(([^)]+)\)/g); + if (mdMatches && mdMatches.length) { + const last = mdMatches[mdMatches.length - 1]; + const urlMatch = last.match(/\[video\]\(([^)]+)\)/); + if (urlMatch) { + return { url: urlMatch[1] }; + } + } + const urlMatches = buffer.match(/https?:\/\/[^\s<)]+/g); + if (urlMatches && urlMatches.length) { + return { url: urlMatches[urlMatches.length - 1] }; + } + return null; + } + + function renderVideoFromHtml(html) { + const container = ensurePreviewSlot(); + if (!container) return; + const body = container.querySelector('.video-item-body'); + if (!body) return; + body.innerHTML = html; + const videoEl = body.querySelector('video'); + let videoUrl = ''; + if (videoEl) { + videoEl.controls = true; + videoEl.preload = 'metadata'; + const source = videoEl.querySelector('source'); + if (source && source.getAttribute('src')) { + videoUrl = source.getAttribute('src'); + } else if (videoEl.getAttribute('src')) { + videoUrl = videoEl.getAttribute('src'); + } + } + updateItemLinks(container, videoUrl); + } + + function renderVideoFromUrl(url) { + const container = ensurePreviewSlot(); + if (!container) return; + const safeUrl = url || ''; + const body = container.querySelector('.video-item-body'); + if (!body) return; + body.innerHTML = `\n \n `; + updateItemLinks(container, safeUrl); + } + + function handleDelta(text) { + if (!text) return; + if (text.includes('') || text.includes('')) { + return; + } + if (text.includes('超分辨率')) { + setStatus('connecting', '超分辨率中'); + setIndeterminate(true); + if (progressText) { + progressText.textContent = '超分辨率中'; + } + return; + } + + if (!collectingContent) { + const maybeVideo = text.includes(' { + setStatus('connected', '生成中'); + }; + + es.onmessage = (event) => { + if (!event || !event.data) return; + if (event.data === '[DONE]') { + finishRun(); + return; + } + let payload = null; + try { + payload = JSON.parse(event.data); + } catch (e) { + return; + } + if (payload && payload.error) { + toast(payload.error, 'error'); + setStatus('error', '生成失败'); + finishRun(true); + return; + } + const choice = payload.choices && payload.choices[0]; + const delta = choice && choice.delta ? choice.delta : null; + if (delta && delta.content) { + handleDelta(delta.content); + } + if (choice && choice.finish_reason === 'stop') { + finishRun(); + } + }; + + es.onerror = () => { + if (!isRunning) return; + setStatus('error', '连接错误'); + finishRun(true); + }; + } + + async function stopConnection() { + const authHeader = await ensurePublicKey(); + if (authHeader !== null) { + await stopVideoTask(currentTaskId, authHeader); + } + closeSource(); + isRunning = false; + currentTaskId = ''; + stopElapsedTimer(); + setButtons(false); + setStatus('', '未连接'); + } + + function finishRun(hasError) { + if (!isRunning) return; + closeSource(); + isRunning = false; + setButtons(false); + stopElapsedTimer(); + if (!hasError) { + setStatus('connected', '完成'); + setIndeterminate(false); + updateProgress(100); + } + if (durationValue && startAt) { + const seconds = Math.max(0, Math.round((Date.now() - startAt) / 1000)); + durationValue.textContent = `耗时 ${seconds}s`; + } + } + + if (startBtn) { + startBtn.addEventListener('click', () => startConnection()); + } + + if (stopBtn) { + stopBtn.addEventListener('click', () => stopConnection()); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => resetOutput()); + } + + if (videoStage) { + videoStage.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (!target.classList.contains('video-download')) return; + event.preventDefault(); + const item = target.closest('.video-item'); + if (!item) return; + const url = item.dataset.url || target.dataset.url || ''; + const index = item.dataset.index || ''; + if (!url) return; + try { + const response = await fetch(url, { mode: 'cors' }); + if (!response.ok) { + throw new Error('download_failed'); + } + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = index ? `grok_video_${index}.mp4` : 'grok_video.mp4'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(blobUrl); + } catch (e) { + toast('下载失败,请检查视频链接是否可访问', 'error'); + } + }); + } + + if (imageFileInput) { + imageFileInput.addEventListener('change', () => { + const file = imageFileInput.files && imageFileInput.files[0]; + if (!file) { + clearFileSelection(); + return; + } + if (imageUrlInput && imageUrlInput.value.trim()) { + imageUrlInput.value = ''; + } + if (imageFileName) { + imageFileName.textContent = file.name; + } + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + fileDataUrl = reader.result; + } else { + fileDataUrl = ''; + toast('文件读取失败', 'error'); + } + }; + reader.onerror = () => { + fileDataUrl = ''; + toast('文件读取失败', 'error'); + }; + reader.readAsDataURL(file); + }); + } + + if (selectImageFileBtn && imageFileInput) { + selectImageFileBtn.addEventListener('click', () => { + imageFileInput.click(); + }); + } + + if (clearImageFileBtn) { + clearImageFileBtn.addEventListener('click', () => { + clearFileSelection(); + }); + } + + if (imageUrlInput) { + imageUrlInput.addEventListener('input', () => { + if (imageUrlInput.value.trim() && fileDataUrl) { + clearFileSelection(); + } + }); + } + + if (promptInput) { + promptInput.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + startConnection(); + } + }); + } + + updateMeta(); +})(); diff --git a/app/static/voice/voice.js b/app/static/public/js/voice.js similarity index 100% rename from app/static/voice/voice.js rename to app/static/public/js/voice.js diff --git a/app/static/imagine/imagine.html b/app/static/public/pages/imagine.html similarity index 63% rename from app/static/imagine/imagine.html rename to app/static/public/pages/imagine.html index bcd5c35..515057f 100644 --- a/app/static/imagine/imagine.html +++ b/app/static/public/pages/imagine.html @@ -5,13 +5,13 @@ Grok2API - Imagine 瀑布流 - + - - - + + + @@ -28,17 +28,41 @@
-
-
生成设置
+
-
-
+
生成设置
+
+
-
-
+
+
+
+
+
自动跟随
+
滚动到最新
+
+ +
+
+
+
自动保存
+
自动保存图片
+
+ +
+
+
+
+
-
+
- - 滚动到最新 - +
+
+
+
自动过滤
+
过滤不达标图片
-
- - + +
+
+
+
+
+ + +
+
+ +
-
- - +
+
+
+
+
反向新增
+
最新显示在上方
+
+
@@ -133,7 +171,10 @@
-
等待连接中...
+
+
等待连接中
+
启动任务后,生成的图片会自动汇聚到这里
+
@@ -196,12 +237,12 @@ - - - - + + + + - + diff --git a/app/static/public/login.html b/app/static/public/pages/login.html similarity index 83% rename from app/static/public/login.html rename to app/static/public/pages/login.html index bcd0a51..3cdc099 100644 --- a/app/static/public/login.html +++ b/app/static/public/pages/login.html @@ -5,7 +5,7 @@ Grok2API - Public - + @@ -24,9 +24,9 @@ } } - - - + + + @@ -59,10 +59,10 @@ - - - - + + + + diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html new file mode 100644 index 0000000..a4f45cb --- /dev/null +++ b/app/static/public/pages/video.html @@ -0,0 +1,168 @@ + + + + + + + Grok2API - Video 生成 + + + + + + + + + + +
+
+ +
+
+
+
+

Video 生成

+

生成短视频,支持参考图与多种预设风格。

+
+
+ + +
+
+ +
+ +
+
+
生成设置
+
+
+ + +
+
+ +
+ +
+
+ 未选择文件 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+
+ +
+
+
运行状态
+ 未连接 +
+
+
+
+
+
+ 0% + 耗时 - +
+
+
+
+
比例
+
-
+
+
+
时长
+
-
+
+
+
分辨率
+
-
+
+
+
预设
+
-
+
+
+
+
+ +
+
+
视频预览
+
+ +
+
+
等待生成视频
+ +
+
+
+ + + + + + + + + + + diff --git a/app/static/voice/voice.html b/app/static/public/pages/voice.html similarity index 92% rename from app/static/voice/voice.html rename to app/static/public/pages/voice.html index a11b148..3299418 100644 --- a/app/static/voice/voice.html +++ b/app/static/public/pages/voice.html @@ -5,13 +5,13 @@ Grok2API - Voice Live - + - - - + + + @@ -147,11 +147,11 @@ - - - - - + + + + + diff --git a/config.defaults.toml b/config.defaults.toml index bbe3898..24bce1f 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -6,10 +6,10 @@ app_url = "http://127.0.0.1:8000" app_key = "grok2api" # API 调用密钥(可选) api_key = "" +# 是否启用 public 功能玩法 +public_enabled = false # Public 调用密钥(可选) public_key = "" -# 是否公开功能玩法(public 入口) -public_enabled = false # 生成图片的格式(url 或 base64) image_format = "url" # 生成视频的格式(html 或 url) @@ -53,9 +53,9 @@ retry_backoff_base = 0.5 # 退避倍率 retry_backoff_factor = 2.0 # 单次重试最大延迟(秒) -retry_backoff_max = 30.0 +retry_backoff_max = 20.0 # 总重试预算时间(秒) -retry_budget = 90.0 +retry_budget = 60.0 # ==================== Token 池管理 ==================== @@ -78,12 +78,12 @@ reload_interval_sec = 30 # 是否启用自动清理 enable_auto_clean = true # 缓存大小上限(MB) -limit_mb = 1024 +limit_mb = 512 # ==================== 对话配置 ==================== [chat] # Reverse 接口并发上限 -concurrent = 10 +concurrent = 50 # Reverse 接口超时时间(秒) timeout = 60 # 流式空闲超时时间(秒) @@ -92,9 +92,9 @@ stream_timeout = 60 # ==================== 图像配置 ==================== [image] # WebSocket 请求超时时间(秒) -timeout = 120 +timeout = 60 # WebSocket 流式空闲超时时间(秒) -stream_timeout = 120 +stream_timeout = 60 # 中等图后等待最终图的超时秒数 final_timeout = 15 # 是否启用 NSFW @@ -107,7 +107,7 @@ final_min_bytes = 100000 # ==================== 视频配置 ==================== [video] # Reverse 接口并发上限 -concurrent = 10 +concurrent = 100 # Reverse 接口超时时间(秒) timeout = 60 # 流式空闲超时时间(秒) @@ -116,44 +116,44 @@ stream_timeout = 60 # ==================== 语音配置 ==================== [voice] # Voice 请求超时时间(秒) -timeout = 120 +timeout = 60 # ==================== 资产配置 ==================== [asset] # 上传并发数 -upload_concurrent = 30 +upload_concurrent = 100 # 上传超时时间(秒) upload_timeout = 60 # 下载并发数 -download_concurrent = 30 +download_concurrent = 100 # 下载超时时间(秒) download_timeout = 60 # 资产查询并发数 -list_concurrent = 10 +list_concurrent = 100 # 资产查询超时时间(秒) list_timeout = 60 # 资产查询批次大小(Token 维度) -list_batch_size = 10 +list_batch_size = 50 # 资产删除并发数 -delete_concurrent = 10 +delete_concurrent = 100 # 资产删除超时时间(秒) delete_timeout = 60 # 资产删除批次大小(Token 维度) -delete_batch_size = 10 +delete_batch_size = 50 # ==================== NSFW ==================== [nsfw] # NSFW 批量开启并发上限 -concurrent = 10 +concurrent = 60 # NSFW 批量开启批次大小 -batch_size = 50 +batch_size = 30 # NSFW 请求超时时间(秒) timeout = 60 # ==================== 用量配置 ==================== [usage] # Usage 批量开启并发上限 -concurrent = 10 +concurrent = 100 # Usage 批量开启批次大小 batch_size = 50 # Usage 请求超时时间(秒) diff --git a/data/config.toml b/data/config.toml index d43d9ef..7ad6fad 100644 --- a/data/config.toml +++ b/data/config.toml @@ -2,8 +2,8 @@ app_url = "http://127.0.0.1:8000" app_key = "grok2api" api_key = "" +public_enabled = true public_key = "" -public_enabled = false image_format = "url" video_format = "html" temporary = true @@ -20,34 +20,13 @@ cf_clearance = "" browser = "chrome136" user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" -[voice] -timeout = 120 - -[chat] -concurrent = 10 -timeout = 60 -stream_timeout = 60 - -[video] -concurrent = 10 -timeout = 60 -stream_timeout = 60 - [retry] max_retry = 3 retry_status_codes = [401,429,403] retry_backoff_base = 0.5 -retry_backoff_factor = 2.0 -retry_backoff_max = 30.0 -retry_budget = 90.0 - -[image] -timeout = 120 -stream_timeout = 120 -final_timeout = 15 -nsfw = true -medium_min_bytes = 30000 -final_min_bytes = 100000 +retry_backoff_factor = 2 +retry_backoff_max = 30 +retry_budget = 90 [token] auto_refresh = true @@ -61,6 +40,27 @@ reload_interval_sec = 30 enable_auto_clean = true limit_mb = 1024 +[chat] +concurrent = 10 +timeout = 60 +stream_timeout = 60 + +[image] +timeout = 120 +stream_timeout = 120 +final_timeout = 15 +nsfw = true +medium_min_bytes = 30000 +final_min_bytes = 100000 + +[video] +concurrent = 10 +timeout = 60 +stream_timeout = 60 + +[voice] +timeout = 120 + [asset] upload_concurrent = 30 upload_timeout = 60 diff --git a/main.py b/main.py index d94218e..f618d87 100644 --- a/main.py +++ b/main.py @@ -124,7 +124,7 @@ def create_app() -> FastAPI: # 注册管理与公共路由 from app.api.v1.admin import router as admin_router from app.api.v1.public import router as public_router - from app.api.v1.pages import router as pages_router + from app.api.pages import router as pages_router app.include_router(admin_router, prefix="/v1/admin") app.include_router(public_router, prefix="/v1/public")