refactor: update configuration settings, enhance public API integration, and streamline service imports while removing deprecated files
This commit is contained in:
1 parent
7f0db522ae
commit
928faabbac
55 files changed
+2700
-506
No files matched your search
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 {
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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"> </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"> </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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user