refactor: update configuration settings, enhance public API integration, and streamline service imports while removing deprecated files

This commit is contained in:
Chenyme committed 2026-02-15 17:43:57 +08:00
1 parent 7f0db522ae
commit 928faabbac
55 files changed
+2700 -506

No files matched your search

+13
View File
@@ -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"]
+32
View File
@@ -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")
+44
View File
@@ -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")
-94
View File
@@ -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")
+2
View File
@@ -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"]
+105 -49
View File
@@ -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}
+274
View File
@@ -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:<mime>;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"]
+14
View File
@@ -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(
+21 -89
View File
@@ -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
+6 -46
View File
@@ -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"</{tag}" in token:
return ""
if self._in_filter_tag:
self._tag_buffer += char
if char == ">":
if "/>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
else:
for tag in self.filter_tags:
if f"</{tag}>" 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."""
+13 -2
View File
@@ -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,
+100 -5
View File
@@ -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
+2
View File
@@ -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",
+12 -5
View File
@@ -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
+109
View File
@@ -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"]
+8
View File
@@ -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:
File renamed without changes.
@@ -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%);
}
File renamed without changes.
File renamed without changes.
@@ -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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>`;
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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>`;
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;
}
File renamed without changes.
File renamed without changes.
@@ -5,13 +5,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - 缓存管理</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/cache/cache.css?v=4" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/admin/css/cache.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
@@ -196,13 +196,13 @@
</div>
</dialog>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/batch-sse.js?v=1"></script>
<script src="/static/common/header.js?v=5"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/cache/cache.js?v=8"></script>
<script src="/static/common/draggable.js"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/batch-sse.js?v=1"></script>
<script src="/static/common/js/header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/admin/js/cache.js?v=1"></script>
<script src="/static/common/js/draggable.js"></script>
</body>
</html>
@@ -5,13 +5,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - 配置管理</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/config/config.css?v=3" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/admin/css/config.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
@@ -46,11 +46,11 @@
</main>
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/header.js?v=5"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/config/config.js?v=14"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/admin/js/config.js?v=1"></script>
</body>
</html>
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - 登录</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
@@ -24,9 +24,9 @@
}
}
</script>
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/login/login.css?v=1" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/common/css/login.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="login-body">
@@ -55,10 +55,10 @@
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/login/login.js?v=1"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/admin/js/login.js?v=1"></script>
</body>
</html>
@@ -5,13 +5,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - Token 管理</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/token/token.css?v=11" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/admin/css/token.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
@@ -291,13 +291,13 @@
</div>
</div>
<script src="/static/common/draggable.js"></script>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/batch-sse.js?v=1"></script>
<script src="/static/common/header.js?v=5"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/token/token.js?v=19"></script>
<script src="/static/common/js/draggable.js"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/batch-sse.js?v=1"></script>
<script src="/static/common/js/header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/admin/js/token.js?v=1"></script>
</body>
</html>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -14,17 +14,9 @@
class="text-xs text-[var(--accents-4)] hover:text-black">@chenyme</a>
</div>
<div class="h-4 w-px bg-[var(--border)]"></div>
<div class="nav-group">
<button type="button" class="nav-link nav-group-trigger text-sm" aria-haspopup="true"
aria-expanded="false">服务管理</button>
<div class="nav-group-menu">
<a href="/admin/token" class="nav-link text-sm" data-nav="/admin/token">Token管理</a>
<a href="/admin/config" class="nav-link text-sm" data-nav="/admin/config">配置管理</a>
<a href="/admin/cache" class="nav-link text-sm" data-nav="/admin/cache">缓存管理</a>
</div>
</div>
<div class="h-4 w-px bg-[var(--border)]"></div>
<a href="/login" class="nav-link text-sm">Public</a>
<a href="/admin/token" class="nav-link text-sm" data-nav="/admin/token">Token管理</a>
<a href="/admin/config" class="nav-link text-sm" data-nav="/admin/config">配置管理</a>
<a href="/admin/cache" class="nav-link text-sm" data-nav="/admin/cache">缓存管理</a>
</div>
<div class="nav-actions">
<button id="storage-mode-btn" type="button" class="nav-action-btn" title="存储模式">-</button>
@@ -15,12 +15,12 @@
</div>
<div class="h-4 w-px bg-[var(--border)]"></div>
<a href="/imagine" class="nav-link text-sm" data-nav="/imagine">Imagine</a>
<a href="/video" class="nav-link text-sm" data-nav="/video">Video</a>
<a href="/voice" class="nav-link text-sm" data-nav="/voice">Voice Live</a>
<a href="/login" class="nav-link text-sm" data-nav="/login">Public Key</a>
</div>
<div class="nav-actions">
<a href="/admin/login" class="nav-action-btn">管理后台</a>
<button onclick="publicLogout()" type="button" class="nav-action-btn">退出</button>
<button id="public-logout-btn" onclick="publicLogout()" type="button" class="nav-action-btn">退出</button>
</div>
</div>
</nav>
File renamed without changes.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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) {
@@ -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;
@@ -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) => {
File renamed without changes.
@@ -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 {
+482
View File
@@ -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);
}
File renamed without changes.
@@ -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) {
File renamed without changes.
+640
View File
@@ -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 = '<div class="video-item-placeholder">生成中…</div>';
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('<video')) {
const matches = buffer.match(/<video[\s\S]*?<\/video>/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 <video controls preload="metadata">\n <source src="${safeUrl}" type="video/mp4">\n </video>\n `;
updateItemLinks(container, safeUrl);
}
function handleDelta(text) {
if (!text) return;
if (text.includes('<think>') || text.includes('</think>')) {
return;
}
if (text.includes('超分辨率')) {
setStatus('connecting', '超分辨率中');
setIndeterminate(true);
if (progressText) {
progressText.textContent = '超分辨率中';
}
return;
}
if (!collectingContent) {
const maybeVideo = text.includes('<video') || text.includes('[video](') || text.includes('http://') || text.includes('https://');
if (maybeVideo) {
collectingContent = true;
}
}
if (collectingContent) {
contentBuffer += text;
const info = extractVideoInfo(contentBuffer);
if (info) {
if (info.html) {
renderVideoFromHtml(info.html);
} else if (info.url) {
renderVideoFromUrl(info.url);
}
}
return;
}
progressBuffer += text;
const matches = [...progressBuffer.matchAll(/进度\s*(\d+)%/g)];
if (matches.length) {
const last = matches[matches.length - 1];
const value = parseInt(last[1], 10);
setIndeterminate(false);
updateProgress(value);
progressBuffer = progressBuffer.slice(Math.max(0, progressBuffer.length - 200));
}
}
function closeSource() {
if (currentSource) {
try {
currentSource.close();
} catch (e) {
// ignore
}
currentSource = null;
}
}
async function startConnection() {
const prompt = promptInput ? promptInput.value.trim() : '';
if (!prompt) {
toast('请输入提示词', 'error');
return;
}
if (isRunning) {
toast('已在生成中', 'warning');
return;
}
const authHeader = await ensurePublicKey();
if (authHeader === null) {
toast('请先配置 Public Key', 'error');
window.location.href = '/login';
return;
}
isRunning = true;
startBtn.disabled = true;
updateMeta();
resetOutput(true);
initPreviewSlot();
setStatus('connecting', '连接中');
let taskId = '';
try {
taskId = await createVideoTask(authHeader);
} catch (e) {
setStatus('error', '创建任务失败');
startBtn.disabled = false;
isRunning = false;
return;
}
currentTaskId = taskId;
startAt = Date.now();
setStatus('connected', '生成中');
setButtons(true);
setIndeterminate(true);
startElapsedTimer();
const rawPublicKey = normalizeAuthHeader(authHeader);
const url = buildSseUrl(taskId, rawPublicKey);
closeSource();
const es = new EventSource(url);
currentSource = es;
es.onopen = () => {
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();
})();
File renamed without changes.
@@ -5,13 +5,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - Imagine 瀑布流</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/imagine/imagine.css?v=4" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/public/css/imagine.css?v=5" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
@@ -28,17 +28,41 @@
<div class="h-px bg-[var(--border)]"></div>
<div class="imagine-top-grid">
<div class="imagine-card imagine-card-collapsible">
<div class="card-title">生成设置</div>
<div class="imagine-card imagine-card-collapsible settings-card">
<div class="card-content" id="settingsContent">
<div class="settings-layout">
<div class="settings-top">
<div class="card-title">生成设置</div>
<div class="settings-grid">
<div class="settings-block settings-block-prompt">
<div class="settings-prompt">
<label class="field-label" for="promptInput">提示词</label>
<textarea id="promptInput" class="geist-input imagine-textarea" placeholder="描述你想要的画面,例如:未来城市霓虹雨夜,广角摄影"></textarea>
</div>
<div class="settings-options">
<div>
</div>
<div class="settings-block settings-block-duo">
<div class="toggle-row">
<div class="toggle-text">
<div class="toggle-title">自动跟随</div>
<div class="toggle-desc">滚动到最新</div>
</div>
<label class="toggle">
<input id="autoScrollToggle" type="checkbox" checked>
<span class="toggle-track"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-text">
<div class="toggle-title">自动保存</div>
<div class="toggle-desc">自动保存图片</div>
</div>
<label class="toggle">
<input id="autoDownloadToggle" type="checkbox">
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="settings-block settings-block-row2">
<div class="settings-row">
<div class="settings-field">
<label class="field-label" for="ratioSelect">宽高比</label>
<select id="ratioSelect" class="geist-input">
<option value="2:3" selected>2:3 竖图</option>
@@ -48,7 +72,7 @@
<option value="9:16">9:16 竖屏</option>
</select>
</div>
<div>
<div class="settings-field">
<label class="field-label" for="concurrentSelect">并发数量</label>
<select id="concurrentSelect" class="geist-input">
<option value="1" selected>1 个任务</option>
@@ -58,34 +82,48 @@
</div>
</div>
</div>
<div class="settings-bottom-row">
<div class="settings-toggles">
<div class="settings-toggle">
<label class="field-label" for="autoScrollToggle">自动跟随</label>
<label class="toggle">
<input id="autoScrollToggle" type="checkbox" checked>
<span class="toggle-track"></span>
<span class="toggle-label">滚动到最新</span>
</label>
<div class="settings-block settings-block-row2b settings-block-single">
<div class="toggle-row toggle-row-fill">
<div class="toggle-text">
<div class="toggle-title">自动过滤</div>
<div class="toggle-desc">过滤不达标图片</div>
</div>
<div class="settings-toggle">
<label class="field-label" for="autoDownloadToggle">自动下载</label>
<label class="toggle">
<input id="autoDownloadToggle" type="checkbox">
<span class="toggle-track"></span>
<span class="toggle-label">自动保存图片</span>
</label>
<label class="toggle">
<input id="autoFilterToggle" type="checkbox">
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="settings-block settings-block-row3">
<div class="settings-row">
<div class="settings-field">
<label class="field-label" for="nsfwSelect">是否 NSFW</label>
<select id="nsfwSelect" class="geist-input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="settings-folder">
<label class="field-label">保存位置</label>
<button id="selectFolderBtn" class="geist-button-outline folder-select-btn" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span id="folderPath">浏览器默认位置</span>
</button>
</div>
</div>
<div class="settings-folder">
<label class="field-label">保存位置</label>
<button id="selectFolderBtn" class="geist-button-outline folder-select-btn" disabled>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span id="folderPath">浏览器默认位置</span>
</button>
</div>
<div class="settings-block settings-block-row3b settings-block-single">
<div class="toggle-row toggle-row-fill">
<div class="toggle-text">
<div class="toggle-title">反向新增</div>
<div class="toggle-desc">最新显示在上方</div>
</div>
<label class="toggle">
<input id="reverseInsertToggle" type="checkbox">
<span class="toggle-track"></span>
</label>
</div>
</div>
</div>
@@ -133,7 +171,10 @@
<div class="waterfall-actions">
</div>
</div>
<div id="emptyState" class="imagine-empty">等待连接中...</div>
<div id="emptyState" class="imagine-empty">
<div class="empty-title">等待连接中</div>
<div class="empty-subtitle">启动任务后,生成的图片会自动汇聚到这里</div>
</div>
<div id="waterfall" class="waterfall"></div>
</div>
</main>
@@ -196,12 +237,12 @@
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/public-header.js?v=1"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/public-header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="/static/imagine/imagine.js?v=8"></script>
<script src="/static/public/js/imagine.js?v=5"></script>
</body>
</html>
@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - Public</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
@@ -24,9 +24,9 @@
}
}
</script>
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/login/login.css?v=1" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/common/css/login.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="login-body">
@@ -59,10 +59,10 @@
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/public/login.js?v=1"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/public/js/login.js?v=1"></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - Video 生成</title>
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/public/css/video.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
</head>
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
<div id="toast-container" class="toast-container"></div>
<div id="app-header"></div>
<main class="flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
<div class="space-y-6">
<div class="video-header-row">
<div>
<h2 class="text-2xl font-semibold tracking-tight">Video 生成</h2>
<p class="text-[var(--accents-4)] mt-1 text-sm">生成短视频,支持参考图与多种预设风格。</p>
</div>
<div class="video-actions">
<button id="startBtn" class="geist-button gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
开始生成
</button>
<button id="stopBtn" class="geist-button-outline gap-2 hidden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="5" width="14" height="14" />
</svg>
停止
</button>
</div>
</div>
<div class="h-px bg-[var(--border)]"></div>
<div class="video-top-grid">
<div class="video-card">
<div class="card-title">生成设置</div>
<div class="settings-grid">
<div class="settings-block prompt-block">
<label class="field-label" for="promptInput">提示词</label>
<textarea id="promptInput" class="geist-input video-textarea" placeholder="例如:街头霓虹雨夜,慢镜头,胶片质感"></textarea>
</div>
<div class="settings-block ref-block">
<label class="field-label" for="imageUrlInput">参考图</label>
<div class="ref-controls">
<input id="imageUrlInput" class="geist-input" placeholder="https://... 或 data:image/...">
</div>
<div class="ref-meta">
<span id="imageFileName" class="ref-name">未选择文件</span>
</div>
</div>
<div class="settings-block ratio-block">
<label class="field-label" for="ratioSelect">画面比例</label>
<select id="ratioSelect" class="geist-input">
<option value="3:2" selected>3:2 横构图</option>
<option value="2:3">2:3 竖构图</option>
<option value="16:9">16:9 宽屏</option>
<option value="9:16">9:16 竖屏</option>
<option value="1:1">1:1 方形</option>
</select>
</div>
<div class="settings-block length-block">
<label class="field-label" for="lengthSelect">视频时长</label>
<select id="lengthSelect" class="geist-input">
<option value="6" selected>6 秒</option>
<option value="10">10 秒</option>
<option value="15">15 秒</option>
</select>
</div>
<div class="settings-block resolution-block">
<label class="field-label" for="resolutionSelect">分辨率</label>
<select id="resolutionSelect" class="geist-input">
<option value="480p" selected>480p</option>
<option value="720p">720p</option>
</select>
</div>
<div class="settings-block preset-block">
<label class="field-label" for="presetSelect">风格预设</label>
<select id="presetSelect" class="geist-input">
<option value="normal" selected>Normal</option>
<option value="fun">Fun</option>
<option value="spicy">Spicy</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="settings-block upload-block">
<label class="field-label">&nbsp;</label>
<button id="selectImageFileBtn" class="geist-button-outline text-xs px-3" type="button">上传</button>
<input id="imageFileInput" class="ref-file-input" type="file" accept="image/*">
</div>
<div class="settings-block clear-block">
<label class="field-label">&nbsp;</label>
<button id="clearImageFileBtn" class="geist-button-outline text-xs px-3" type="button">清除</button>
</div>
</div>
</div>
<div class="video-card">
<div class="status-header">
<div class="card-title">运行状态</div>
<span id="statusText" class="status-text">未连接</span>
</div>
<div class="progress-wrap">
<div id="progressBar" class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="progress-meta">
<span id="progressText">0%</span>
<span id="durationValue">耗时 -</span>
</div>
</div>
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">比例</div>
<div id="aspectValue" class="meta-value">-</div>
</div>
<div class="meta-item">
<div class="meta-label">时长</div>
<div id="lengthValue" class="meta-value">-</div>
</div>
<div class="meta-item">
<div class="meta-label">分辨率</div>
<div id="resolutionValue" class="meta-value">-</div>
</div>
<div class="meta-item">
<div class="meta-label">预设</div>
<div id="presetValue" class="meta-value">-</div>
</div>
</div>
</div>
</div>
<div class="video-card video-preview-card">
<div class="video-preview-header">
<div class="card-title">视频预览</div>
<div class="preview-actions">
<button id="clearBtn" class="geist-button-outline text-xs px-3">清空</button>
</div>
</div>
<div id="videoEmpty" class="video-empty">等待生成视频</div>
<div id="videoStage" class="video-stage hidden"></div>
</div>
</div>
</main>
<div id="app-footer"></div>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/public-header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/public/js/video.js?v=1"></script>
</body>
</html>
@@ -5,13 +5,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grok2API - Voice Live</title>
<link rel="icon" href="/static/favicon/favicon.ico">
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
<link href="/static/common/common.css?v=3" rel="stylesheet">
<link href="/static/voice/voice.css?v=15" rel="stylesheet">
<link href="/static/common/toast.css" rel="stylesheet">
<link href="/static/common/css/common.css?v=1" rel="stylesheet">
<link href="/static/public/css/voice.css?v=1" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.7.3/dist/livekit-client.umd.min.js"></script>
</head>
@@ -147,11 +147,11 @@
<div id="app-footer"></div>
<script src="/static/common/admin-auth.js?v=2"></script>
<script src="/static/common/public-header.js?v=1"></script>
<script src="/static/common/footer.js?v=1"></script>
<script src="/static/common/toast.js"></script>
<script src="/static/voice/voice.js?v=8"></script>
<script src="/static/common/js/admin-auth.js?v=1"></script>
<script src="/static/common/js/public-header.js?v=1"></script>
<script src="/static/common/js/footer.js?v=1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/public/js/voice.js?v=1"></script>
</body>
</html>
+19 -19
View File
@@ -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 请求超时时间(秒)
+25 -25
View File
@@ -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
+1 -1
View File
@@ -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")